From 3f5618bb96cb9d897e197a0ce59bad7300af8549 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 3 Aug 2026 11:17:42 -0500 Subject: [PATCH 01/22] Add EventListeners a SapDelegate when Sappy is present --- .../src/EventHandling/EventListeners.cs | 73 +++++++++++---- sdks/csharp/src/Table.cs | 88 +++++++++++++++---- 2 files changed, 127 insertions(+), 34 deletions(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index d4acc4b7218..f11347043ed 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -3,38 +3,81 @@ namespace SpacetimeDB.EventHandling { - internal class EventListeners where T : Delegate + public class EventListeners where T : Delegate { - private List List { get; } - private Dictionary Indices { get; } +#if SAPPY + public SapDelegate Targets { get; } = new(); + private Dictionary> Cache { get; } = new(4); - public int Count => List.Count; + public void Add(SapTarget listener) => Targets.Add(listener); + public void Remove(SapTarget listener) => Targets.Remove(listener); - public T this[int index] => List[index]; + public int Count => Targets.Count; + + public T this[int index] => Targets[index]; - public EventListeners() : this(0) { } - public EventListeners(int initialSize) + public void Add(T listener) + { + if(listener == null) return; + var hashCode = listener.GetHashCode(); + if(!Cache.TryGetValue(hashCode, out var target)) { + target = new SapTarget(listener); + Cache.Add(hashCode, target); + } + Add(target); + } + public void Remove(T listener) + { + if(listener == null || !Cache.TryGetValue(listener.GetHashCode(), out var target)) return; + Remove(target); + } + + public static EventListeners operator +(EventListeners a, SapTarget b) + { + a.Add(b); + return a; + } + public static EventListeners operator -(EventListeners a, SapTarget b) + { + a.Remove(b); + return a; + } + public static EventListeners operator +(EventListeners a, T b) { - List = new List(initialSize); - Indices = new Dictionary(initialSize); + a.Add(b); + return a; } + public static EventListeners operator -(EventListeners a, T b) + { + a.Remove(b); + return a; + } +#else + private List List { get; } = new(4); + private Dictionary Indices { get; } = new(4); + + public int Count => List.Count; + + public T this[int index] => List[index]; public void Add(T listener) { - if (listener == null || !Indices.TryAdd(listener, List.Count)) return; + if (listener == null || !Indices.TryAdd(listener.GetHashCode(), List.Count)) return; List.Add(listener); } - public void Remove(T listener) { - if (listener == null || List.Count <= 0 || !Indices.Remove(listener, out var index)) return; + if (listener == null || List.Count <= 0) return; + var hashCode = listener.GetHashCode(); + if(!Indices.Remove(hashCode, out var index)) return; var lastListener = List[^1]; - if (lastListener != listener) + var lastListenerHashCode = lastListener.GetHashCode(); + if (lastListenerHashCode != hashCode) { - Indices[lastListener] = index; + Indices[lastListenerHashCode] = index; } - List.RemoveAtSwapBack(index); } +#endif } } \ No newline at end of file diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 5847f5728df..edc0801ffaf 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -408,11 +408,19 @@ void IRemoteTableHandle.Parse(TableUpdate update, ParsedDatabaseUpdate dbOps) public delegate void RowEventHandler(EventContext context, Row row); private CustomRowEventHandler OnInsertHandler { get; } = new(); +#if SAPPY + public EventListeners OnInsert + { + get => OnInsertHandler.Listeners; + set => OnInsertHandler.Listeners = value; + } +#else public event RowEventHandler OnInsert { - add => OnInsertHandler.AddListener(value); - remove => OnInsertHandler.RemoveListener(value); + add => OnInsertHandler.Listeners.Add(value); + remove => OnInsertHandler.Listeners.Remove(value); } +#endif public delegate void UpdateEventHandler(EventContext context, Row oldRow, Row newRow); public int Count => (int)Entries.CountDistinct; @@ -577,33 +585,51 @@ void IRemoteTableHandle.PostApply(IEventContext context) protected class CustomRowEventHandler { - private EventListeners Listeners { get; } = new(); + private EventListeners _listeners = new(); + public EventListeners Listeners + { + get => _listeners; + set + { + if (_listeners != null && value != _listeners) + { + throw new InvalidOperationException("You can't override the targets of a SapStem."); + } + _listeners = value; + } + } public void Invoke(EventContext ctx, Row row) { for (var i = Listeners.Count - 1; i >= 0; i--) { - Listeners[i]?.Invoke(ctx, row); + _listeners[i]?.Invoke(ctx, row); } } - - public void AddListener(RowEventHandler listener) => Listeners.Add(listener); - public void RemoveListener(RowEventHandler listener) => Listeners.Remove(listener); } protected class CustomUpdateEventHandler { - private EventListeners Listeners { get; } = new(); + private EventListeners _listeners = new(); + public EventListeners Listeners + { + get => _listeners; + set + { + if (_listeners != null && value != _listeners) + { + throw new InvalidOperationException("You can't override the targets of a SapStem."); + } + _listeners = value; + } + } public void Invoke(EventContext ctx, Row oldRow, Row newRow) { - for (var i = Listeners.Count - 1; i >= 0; i--) + for (var i = _listeners.Count - 1; i >= 0; i--) { - Listeners[i]?.Invoke(ctx, oldRow, newRow); + _listeners[i]?.Invoke(ctx, oldRow, newRow); } } - - public void AddListener(UpdateEventHandler listener) => Listeners.Add(listener); - public void RemoveListener(UpdateEventHandler listener) => Listeners.Remove(listener); } } @@ -617,23 +643,47 @@ public abstract class RemoteTableHandle : RemoteTableHandleBa protected RemoteTableHandle(IDbConnection conn) : base(conn) { } private CustomRowEventHandler OnDeleteHandler { get; } = new(); +#if SAPPY + public EventListeners OnDelete + { + get => OnDeleteHandler.Listeners; + set => OnDeleteHandler.Listeners = value; + } +#else public event RowEventHandler OnDelete { - add => OnDeleteHandler.AddListener(value); - remove => OnDeleteHandler.RemoveListener(value); + add => OnDeleteHandler.Listeners.Add(value); + remove => OnDeleteHandler.Listeners.Remove(value); } +#endif private CustomRowEventHandler OnBeforeDeleteHandler { get; } = new(); +#if SAPPY + public EventListeners OnBeforeDelete + { + get => OnBeforeDeleteHandler.Listeners; + set => OnBeforeDeleteHandler.Listeners = value; + } +#else public event RowEventHandler OnBeforeDelete { - add => OnBeforeDeleteHandler.AddListener(value); - remove => OnBeforeDeleteHandler.RemoveListener(value); + add => OnBeforeDeleteHandler.Listeners.Add(value); + remove => OnBeforeDeleteHandler.Listeners.Remove(value); } +#endif private CustomUpdateEventHandler OnUpdateHandler { get; } = new(); +#if SAPPY + public EventListeners OnUpdate + { + get => OnUpdateHandler.Listeners; + set => OnUpdateHandler.Listeners = value; + } +#else public event UpdateEventHandler OnUpdate { - add => OnUpdateHandler.AddListener(value); - remove => OnUpdateHandler.RemoveListener(value); + add => OnUpdateHandler.Listeners.Add(value); + remove => OnUpdateHandler.Listeners.Remove(value); } +#endif protected override void InvokeDelete(IEventContext context, IStructuralReadWrite row) { From 103815d819af525652f9c18d5a26e8d95849205c Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 3 Aug 2026 11:22:17 -0500 Subject: [PATCH 02/22] Improve collision handling when Sappy is not present --- .../src/EventHandling/EventListeners.cs | 244 +++++++++++++++++- 1 file changed, 235 insertions(+), 9 deletions(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index f11347043ed..dcfaa5109ba 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -53,8 +53,13 @@ public void Remove(T listener) return a; } #else + private const int SmallListenerThreshold = 8; + private const int CollisionBucket = -1; + private List List { get; } = new(4); - private Dictionary Indices { get; } = new(4); + private Dictionary? Indices { get; set; } + private Dictionary>? Collisions { get; set; } + private Stack>? CollisionsPool { get; set; } public int Count => List.Count; @@ -62,22 +67,243 @@ public void Remove(T listener) public void Add(T listener) { - if (listener == null || !Indices.TryAdd(listener.GetHashCode(), List.Count)) return; + if (listener == null) return; + + var hashCode = listener.GetHashCode(); + + if (List.Count <= SmallListenerThreshold) + { + if (FindLinear(listener) >= 0) return; + + List.Add(listener); + + if (List.Count > SmallListenerThreshold) + { + RebuildIndex(); + } + + return; + } + + var indices = Indices!; + + if (!indices.TryGetValue(hashCode, out var index)) + { + indices.Add(hashCode, List.Count); + List.Add(listener); + return; + } + + if (index != CollisionBucket) + { + if (DelegateEquals(List[index], listener)) return; + + var newIndex = List.Count; + List.Add(listener); + Collisions ??= new Dictionary>(); + Collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); + indices[hashCode] = CollisionBucket; + return; + } + + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + if (DelegateEquals(List[bucket[i]], listener)) return; + } + + bucket.Add(List.Count); List.Add(listener); } public void Remove(T listener) { if (listener == null || List.Count <= 0) return; + var hashCode = listener.GetHashCode(); - if(!Indices.Remove(hashCode, out var index)) return; - var lastListener = List[^1]; - var lastListenerHashCode = lastListener.GetHashCode(); - if (lastListenerHashCode != hashCode) + + if (List.Count <= SmallListenerThreshold) + { + var index = FindLinear(listener); + if (index >= 0) + { + List.RemoveAtSwapBack(index); + ClearIndex(); + } + + return; + } + + var indices = Indices!; + + if (!indices.TryGetValue(hashCode, out var mappedIndex)) return; + + var removeIndex = -1; + + if (mappedIndex != CollisionBucket) + { + if (!DelegateEquals(List[mappedIndex], listener)) return; + + removeIndex = mappedIndex; + indices.Remove(hashCode); + } + else + { + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + var candidate = bucket[i]; + if (!DelegateEquals(List[candidate], listener)) continue; + + removeIndex = candidate; + RemoveBucketSlot(bucket, i); + + if (bucket.Count == 1) + { + indices[hashCode] = bucket[0]; + Collisions.Remove(hashCode); + ReturnCollisionsListToPool(bucket); + } + else if (bucket.Count == 0) + { + indices.Remove(hashCode); + Collisions.Remove(hashCode); + ReturnCollisionsListToPool(bucket); + } + + break; + } + + if (removeIndex < 0) return; + } + + var movedFrom = List.Count - 1; + var movedHashCode = List[movedFrom].GetHashCode(); + + List.RemoveAtSwapBack(removeIndex); + + if (List.Count <= SmallListenerThreshold) + { + ClearIndex(); + } + else if (removeIndex != movedFrom) + { + UpdateMovedIndex(movedHashCode, movedFrom, removeIndex); + } + } + + private int FindLinear(T listener) + { + for (var i = 0; i < List.Count; i++) { - Indices[lastListenerHashCode] = index; + if (DelegateEquals(List[i], listener)) return i; } - List.RemoveAtSwapBack(index); + + return -1; } + + private void RebuildIndex() + { + if (Indices == null) + { + Indices = new Dictionary(List.Count); + } + else + { + ClearIndex(); + } + + for (var i = 0; i < List.Count; i++) + { + var hashCode = List[i].GetHashCode(); + + if (!Indices.TryGetValue(hashCode, out var existing)) + { + Indices.Add(hashCode, i); + continue; + } + + Collisions ??= new Dictionary>(); + + if (existing != CollisionBucket) + { + Collisions[hashCode] = GetCollisionsListFromPool(existing, i); + Indices[hashCode] = CollisionBucket; + } + else + { + Collisions[hashCode].Add(i); + } + } + } + + private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) + { + var mappedIndex = Indices![hashCode]; + + if (mappedIndex != CollisionBucket) + { + Indices[hashCode] = newIndex; + return; + } + + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + if (bucket[i] == oldIndex) + { + bucket[i] = newIndex; + return; + } + } + } + + private static void RemoveBucketSlot(List bucket, int slot) + { + var lastSlot = bucket.Count - 1; + + if (slot != lastSlot) + { + bucket[slot] = bucket[lastSlot]; + } + + bucket.RemoveAt(lastSlot); + } + + private void ClearIndex() + { + Indices?.Clear(); + + if (Collisions == null) return; + + foreach (var collisions in Collisions.Values) + { + ReturnCollisionsListToPool(collisions); + } + + Collisions.Clear(); + } + + private List GetCollisionsListFromPool(int a, int b) + { + if (CollisionsPool == null || CollisionsPool.Count <= 0) return new List(2) { a, b }; + + var list = CollisionsPool.Pop(); + list.Add(a); + list.Add(b); + return list; + } + + private void ReturnCollisionsListToPool(List list) + { + list.Clear(); + CollisionsPool ??= new Stack>(); + CollisionsPool.Push(list); + } + + private static bool DelegateEquals(T a, T b) => EqualityComparer.Default.Equals(a, b); #endif } -} \ No newline at end of file +} From 8b16b7b3b6d4f9e0faee9220dcf6db2e045f18b6 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 11 Aug 2026 12:42:11 -0500 Subject: [PATCH 03/22] Clean --- .../src/EventHandling/AbstractEventHandler.cs | 100 ------------------ .../AbstractEventHandler.cs.meta | 11 -- sdks/csharp/src/Table.cs | 22 ++-- sdks/csharp/src/WebSocket.cs | 1 + .../com.clockworklabs.spacetimedbsdk.asmdef | 8 ++ 5 files changed, 15 insertions(+), 127 deletions(-) delete mode 100644 sdks/csharp/src/EventHandling/AbstractEventHandler.cs delete mode 100644 sdks/csharp/src/EventHandling/AbstractEventHandler.cs.meta diff --git a/sdks/csharp/src/EventHandling/AbstractEventHandler.cs b/sdks/csharp/src/EventHandling/AbstractEventHandler.cs deleted file mode 100644 index 1d6e03b4ada..00000000000 --- a/sdks/csharp/src/EventHandling/AbstractEventHandler.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; - -namespace SpacetimeDB.EventHandling -{ - internal class AbstractEventHandler - { - private EventListeners Listeners { get; } = new(); - - public void Invoke() - { - for (var i = Listeners.Count - 1; i >= 0; i--) - { - Listeners[i]?.Invoke(); - } - } - - public void AddListener(Action listener) => Listeners.Add(listener); - - public void RemoveListener(Action listener) => Listeners.Remove(listener); - } - internal class AbstractEventHandler - { - private EventListeners> Listeners { get; } = new(); - - public void Invoke(T value) - { - for (var i = Listeners.Count - 1; i >= 0; i--) - { - Listeners[i]?.Invoke(value); - } - } - - public void AddListener(Action listener) => Listeners.Add(listener); - public void RemoveListener(Action listener) => Listeners.Remove(listener); - } - - internal class AbstractEventHandler - { - private EventListeners> Listeners { get; } = new(); - - public void Invoke(T1 v1, T2 v2) - { - for (var i = Listeners.Count - 1; i >= 0; i--) - { - Listeners[i]?.Invoke(v1, v2); - } - } - - public void AddListener(Action listener) => Listeners.Add(listener); - public void RemoveListener(Action listener) => Listeners.Remove(listener); - } - - internal class AbstractEventHandler - { - private EventListeners> Listeners { get; } = new(); - - public void Invoke(T1 v1, T2 v2, T3 v3) - { - for (var i = Listeners.Count - 1; i >= 0; i--) - { - Listeners[i]?.Invoke(v1, v2, v3); - } - } - - public void AddListener(Action listener) => Listeners.Add(listener); - public void RemoveListener(Action listener) => Listeners.Remove(listener); - } - - internal class AbstractEventHandler - { - private EventListeners> Listeners { get; } = new(); - - public void Invoke(T1 v1, T2 v2, T3 v3, T4 v4) - { - for (var i = Listeners.Count - 1; i >= 0; i--) - { - Listeners[i]?.Invoke(v1, v2, v3, v4); - } - } - - public void AddListener(Action listener) => Listeners.Add(listener); - public void RemoveListener(Action listener) => Listeners.Remove(listener); - } - - internal class AbstractEventHandler - { - private EventListeners> Listeners { get; } = new(); - - public void Invoke(T1 v1, T2 v2, T3 v3, T4 v4, T5 v5) - { - for (var i = Listeners.Count - 1; i >= 0; i--) - { - Listeners[i]?.Invoke(v1, v2, v3, v4, v5); - } - } - - public void AddListener(Action listener) => Listeners.Add(listener); - public void RemoveListener(Action listener) => Listeners.Remove(listener); - } -} \ No newline at end of file diff --git a/sdks/csharp/src/EventHandling/AbstractEventHandler.cs.meta b/sdks/csharp/src/EventHandling/AbstractEventHandler.cs.meta deleted file mode 100644 index 2ceef79f6e5..00000000000 --- a/sdks/csharp/src/EventHandling/AbstractEventHandler.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a3ff844e9ff394788a1bc7e8e83ac86b -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index edc0801ffaf..73af691718e 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -196,18 +196,8 @@ public RemoteTableHandleBase(IDbConnection conn, bool isEventTable = false) : ba // 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 - { - add => OnInternalInsertHandler.AddListener(value); - remove => OnInternalInsertHandler.RemoveListener(value); - } - private AbstractEventHandler OnInternalDeleteHandler { get; } = new(); - private event Action OnInternalDelete - { - add => OnInternalDeleteHandler.AddListener(value); - remove => OnInternalDeleteHandler.RemoveListener(value); - } + private event Action OnInternalInsert; + private event Action OnInternalDelete; // These are implementations of the type-erased interface. object? IRemoteTableHandle.GetPrimaryKey(IStructuralReadWrite row) => GetPrimaryKey((Row)row); @@ -516,14 +506,14 @@ void IRemoteTableHandle.Apply(IEventContext context, IParsedTableUpdate parsedTa { if (value is Row oldRow) { - OnInternalDeleteHandler.Invoke(oldRow); + OnInternalDelete?.Invoke(oldRow); } } foreach (var (_, value) in wasInserted) { if (value is Row newRow) { - OnInternalInsertHandler.Invoke(newRow); + OnInternalInsert?.Invoke(newRow); } else { @@ -534,7 +524,7 @@ void IRemoteTableHandle.Apply(IEventContext context, IParsedTableUpdate parsedTa { if (oldValue is Row oldRow) { - OnInternalDeleteHandler.Invoke(oldRow); + OnInternalDelete?.Invoke(oldRow); } else { @@ -544,7 +534,7 @@ void IRemoteTableHandle.Apply(IEventContext context, IParsedTableUpdate parsedTa if (newValue is Row newRow) { - OnInternalInsertHandler.Invoke(newRow); + OnInternalInsert?.Invoke(newRow); } else { diff --git a/sdks/csharp/src/WebSocket.cs b/sdks/csharp/src/WebSocket.cs index 26ce87127ba..c487f75171e 100644 --- a/sdks/csharp/src/WebSocket.cs +++ b/sdks/csharp/src/WebSocket.cs @@ -48,6 +48,7 @@ public WebSocket(ConnectOptions options) #endif } + // TODO: This never has subscriptions public event OpenEventHandler? OnConnect; public event ConnectErrorEventHandler? OnConnectError; public event SendErrorEventHandler? OnSendError; diff --git a/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef b/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef index ab9733e7bcb..66fa07cdb5a 100644 --- a/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef +++ b/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef @@ -1,3 +1,11 @@ { "name": "com.clockworklabs.spacetimedbsdk" + "rootNamespace": "SpacetimeDB", + "versionDefines": [ + { + "name": "io.clockworklabs.sappy", + "expression": "1.0.1", + "define": "SAPPY" + } + ], } From 56e0debbe108b27865373c2c2759bc3588e0991e Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 11 Aug 2026 13:25:20 -0500 Subject: [PATCH 04/22] Fix Unity package .asmdef --- sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef b/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef index 66fa07cdb5a..5ccda17039a 100644 --- a/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef +++ b/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef @@ -1,5 +1,5 @@ { - "name": "com.clockworklabs.spacetimedbsdk" + "name": "com.clockworklabs.spacetimedbsdk", "rootNamespace": "SpacetimeDB", "versionDefines": [ { @@ -7,5 +7,5 @@ "expression": "1.0.1", "define": "SAPPY" } - ], + ] } From 7c8ffa779172b6e97a8adc1cabdc72b404437060 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 08:33:35 -0500 Subject: [PATCH 05/22] Start SappyIntegration --- .../src/EventHandling/EventListeners.cs | 56 +++--------------- .../SappyIntegration/SappyEventListeners.cs | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+), 48 deletions(-) create mode 100644 sdks/csharp/src/SappyIntegration/SappyEventListeners.cs diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index dcfaa5109ba..0e2f405e40b 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -1,58 +1,19 @@ using System; using System.Collections.Generic; +#if SAPPY +using Sappy; +#endif namespace SpacetimeDB.EventHandling { - public class EventListeners where T : Delegate + public interface IEventListeners where T : Delegate { -#if SAPPY - public SapDelegate Targets { get; } = new(); - private Dictionary> Cache { get; } = new(4); - - public void Add(SapTarget listener) => Targets.Add(listener); - public void Remove(SapTarget listener) => Targets.Remove(listener); - - public int Count => Targets.Count; + T this[int index] { get; } - public T this[int index] => Targets[index]; - - public void Add(T listener) - { - if(listener == null) return; - var hashCode = listener.GetHashCode(); - if(!Cache.TryGetValue(hashCode, out var target)) { - target = new SapTarget(listener); - Cache.Add(hashCode, target); - } - Add(target); - } - public void Remove(T listener) - { - if(listener == null || !Cache.TryGetValue(listener.GetHashCode(), out var target)) return; - Remove(target); - } + } - public static EventListeners operator +(EventListeners a, SapTarget b) - { - a.Add(b); - return a; - } - public static EventListeners operator -(EventListeners a, SapTarget b) - { - a.Remove(b); - return a; - } - public static EventListeners operator +(EventListeners a, T b) - { - a.Add(b); - return a; - } - public static EventListeners operator -(EventListeners a, T b) - { - a.Remove(b); - return a; - } -#else + public class EventListeners where T : Delegate + { private const int SmallListenerThreshold = 8; private const int CollisionBucket = -1; @@ -304,6 +265,5 @@ private void ReturnCollisionsListToPool(List list) } private static bool DelegateEquals(T a, T b) => EqualityComparer.Default.Equals(a, b); -#endif } } diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs new file mode 100644 index 00000000000..8e0f8afb8e7 --- /dev/null +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using Sappy; +using SpacetimeDB.EventHandling; + +namespace SpacetimeDB.SappyIntegration +{ + public class SappyEventListeners : EventListeners where T : Delegate + { + private SapDelegate Targets { get; } = new(); + private Dictionary> Cache { get; } = new(4); + + public void Add(SapTarget listener) => Targets.Add(listener); + public void Remove(SapTarget listener) => Targets.Remove(listener); + + public int Count => Targets.Count; + + public T this[int index] => Targets[index]; + + public void Add(T listener) + { + if(listener == null) return; + var hashCode = listener.GetHashCode(); + if(!Cache.TryGetValue(hashCode, out var target)) { + target = new SapTarget(listener); + Cache.Add(hashCode, target); + } + Add(target); + } + public void Remove(T listener) + { + if(listener == null || !Cache.TryGetValue(listener.GetHashCode(), out var target)) return; + Remove(target); + } + + public static EventListeners operator +(SappyEventListeners a, SapTarget b) + { + a.Add(b); + return a; + } + public static EventListeners operator -(SappyEventListeners a, SapTarget b) + { + a.Remove(b); + return a; + } + public static EventListeners operator +(SappyEventListeners a, T b) + { + a.Add(b); + return a; + } + public static EventListeners operator -(SappyEventListeners a, T b) + { + a.Remove(b); + return a; + } + } +} \ No newline at end of file From e2246eebb7b3e7f997a48d81ffa2ed6b945f5be1 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 14:44:16 -0500 Subject: [PATCH 06/22] Implement DI --- .../src/EventHandling/EventListeners.cs | 26 ++++++++++++++++--- .../SappyIntegration/SappyEventListeners.cs | 14 +++++----- .../SappyEventListenersFactory.cs | 20 ++++++++++++++ ...abs.spacetimedbsdk.sappyintegration.asmdef | 18 +++++++++++++ sdks/csharp/src/Table.cs | 8 +++--- .../com.clockworklabs.spacetimedbsdk.asmdef | 2 +- 6 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs create mode 100644 sdks/csharp/src/SappyIntegration/com.clockworklabs.spacetimedbsdk.sappyintegration.asmdef diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index 0e2f405e40b..5b8b272653b 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -1,18 +1,36 @@ using System; using System.Collections.Generic; -#if SAPPY -using Sappy; -#endif namespace SpacetimeDB.EventHandling { public interface IEventListeners where T : Delegate { + int Count { get; } T this[int index] { get; } + + void Add(T listener); + void Remove(T listener); + } + + public interface IEventListenersFactory + { + IEventListeners Create() where T : Delegate; + } + + public static class EventListenersProvider + { + private static IEventListenersFactory? CustomFactory { get; set; } + + public static IEventListeners Create() where T : Delegate => CustomFactory?.Create() ?? new BasicEventListeners(); + public static void SetFactory(IEventListenersFactory factory) + { + if(CustomFactory != null) throw new InvalidOperationException("EventListenersFactory can only be set once."); + CustomFactory = factory; + } } - public class EventListeners where T : Delegate + public class BasicEventListeners : IEventListeners where T : Delegate { private const int SmallListenerThreshold = 8; private const int CollisionBucket = -1; diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs index 8e0f8afb8e7..5bdae153b88 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -1,3 +1,4 @@ +#if SAPPY using System; using System.Collections.Generic; using Sappy; @@ -5,7 +6,7 @@ namespace SpacetimeDB.SappyIntegration { - public class SappyEventListeners : EventListeners where T : Delegate + public class SappyEventListeners : IEventListeners where T : Delegate { private SapDelegate Targets { get; } = new(); private Dictionary> Cache { get; } = new(4); @@ -33,25 +34,26 @@ public void Remove(T listener) Remove(target); } - public static EventListeners operator +(SappyEventListeners a, SapTarget b) + public static BasicEventListeners operator +(SappyEventListeners a, SapTarget b) { a.Add(b); return a; } - public static EventListeners operator -(SappyEventListeners a, SapTarget b) + public static BasicEventListeners operator -(SappyEventListeners a, SapTarget b) { a.Remove(b); return a; } - public static EventListeners operator +(SappyEventListeners a, T b) + public static BasicEventListeners operator +(SappyEventListeners a, T b) { a.Add(b); return a; } - public static EventListeners operator -(SappyEventListeners a, T b) + public static BasicEventListeners operator -(SappyEventListeners a, T b) { a.Remove(b); return a; } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs b/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs new file mode 100644 index 00000000000..b4497116b98 --- /dev/null +++ b/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs @@ -0,0 +1,20 @@ +#if SAPPY +using System; +using UnityEngine; +using SpacetimeDB.EventHandling; + +namespace SpacetimeDB.SappyIntegration +{ + public class SappyEventListenersFactory : IEventListenersFactory + { + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + private static void AutoRegister() + { + // Hand this implementation back to the main assembly + EventListenersProvider.SetFactory(new SappyEventListenersFactory()); + } + + public IEventListeners Create() where T : Delegate => new SappyEventListeners(); + } +} +#endif \ No newline at end of file diff --git a/sdks/csharp/src/SappyIntegration/com.clockworklabs.spacetimedbsdk.sappyintegration.asmdef b/sdks/csharp/src/SappyIntegration/com.clockworklabs.spacetimedbsdk.sappyintegration.asmdef new file mode 100644 index 00000000000..e72d9382a6e --- /dev/null +++ b/sdks/csharp/src/SappyIntegration/com.clockworklabs.spacetimedbsdk.sappyintegration.asmdef @@ -0,0 +1,18 @@ +{ + "name": "com.clockworklabs.spacetimedbsdk.sappyintegration", + "rootNamespace": "SpacetimeDB", + "references": [ + "com.clockworklabs.spacetimedbsdk", + "ClockworkLabs.Sappy" + ], + "defineConstraints": [ + "SAPPY" + ], + "versionDefines": [ + { + "name": "io.clockworklabs.sappy", + "expression": "1.1.0", + "define": "SAPPY" + } + ] +} diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 73af691718e..c034ddb09bd 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -575,8 +575,8 @@ void IRemoteTableHandle.PostApply(IEventContext context) protected class CustomRowEventHandler { - private EventListeners _listeners = new(); - public EventListeners Listeners + private IEventListeners _listeners = EventListenersProvider.Create(); + public IEventListeners Listeners { get => _listeners; set @@ -599,8 +599,8 @@ public void Invoke(EventContext ctx, Row row) } protected class CustomUpdateEventHandler { - private EventListeners _listeners = new(); - public EventListeners Listeners + private IEventListeners _listeners = EventListenersProvider.Create(); + public IEventListeners Listeners { get => _listeners; set diff --git a/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef b/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef index 5ccda17039a..73774dfbaa7 100644 --- a/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef +++ b/sdks/csharp/src/com.clockworklabs.spacetimedbsdk.asmdef @@ -4,7 +4,7 @@ "versionDefines": [ { "name": "io.clockworklabs.sappy", - "expression": "1.0.1", + "expression": "1.1.0", "define": "SAPPY" } ] From b292f05c2fe6b8025a62c860682b5260a479f499 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 14:48:56 -0500 Subject: [PATCH 07/22] Fix compilation errors --- sdks/csharp/src/SappyIntegration/SappyEventListeners.cs | 8 ++++---- sdks/csharp/src/Table.cs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs index 5bdae153b88..b1a9265c719 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -34,22 +34,22 @@ public void Remove(T listener) Remove(target); } - public static BasicEventListeners operator +(SappyEventListeners a, SapTarget b) + public static SappyEventListeners operator +(SappyEventListeners a, SapTarget b) { a.Add(b); return a; } - public static BasicEventListeners operator -(SappyEventListeners a, SapTarget b) + public static SappyEventListeners operator -(SappyEventListeners a, SapTarget b) { a.Remove(b); return a; } - public static BasicEventListeners operator +(SappyEventListeners a, T b) + public static SappyEventListeners operator +(SappyEventListeners a, T b) { a.Add(b); return a; } - public static BasicEventListeners operator -(SappyEventListeners a, T b) + public static SappyEventListeners operator -(SappyEventListeners a, T b) { a.Remove(b); return a; diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index c034ddb09bd..31452edc441 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -399,7 +399,7 @@ void IRemoteTableHandle.Parse(TableUpdate update, ParsedDatabaseUpdate dbOps) public delegate void RowEventHandler(EventContext context, Row row); private CustomRowEventHandler OnInsertHandler { get; } = new(); #if SAPPY - public EventListeners OnInsert + public IEventListeners OnInsert { get => OnInsertHandler.Listeners; set => OnInsertHandler.Listeners = value; @@ -634,7 +634,7 @@ protected RemoteTableHandle(IDbConnection conn) : base(conn) { } private CustomRowEventHandler OnDeleteHandler { get; } = new(); #if SAPPY - public EventListeners OnDelete + public IEventListeners OnDelete { get => OnDeleteHandler.Listeners; set => OnDeleteHandler.Listeners = value; @@ -648,7 +648,7 @@ public event RowEventHandler OnDelete #endif private CustomRowEventHandler OnBeforeDeleteHandler { get; } = new(); #if SAPPY - public EventListeners OnBeforeDelete + public IEventListeners OnBeforeDelete { get => OnBeforeDeleteHandler.Listeners; set => OnBeforeDeleteHandler.Listeners = value; @@ -662,7 +662,7 @@ public event RowEventHandler OnBeforeDelete #endif private CustomUpdateEventHandler OnUpdateHandler { get; } = new(); #if SAPPY - public EventListeners OnUpdate + public IEventListeners OnUpdate { get => OnUpdateHandler.Listeners; set => OnUpdateHandler.Listeners = value; From 0ec2b483e94ef5d3729a1018e6c20ea744807358 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 14:53:36 -0500 Subject: [PATCH 08/22] Allow overriding CustomFactory --- sdks/csharp/src/EventHandling/EventListeners.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index 5b8b272653b..ff21e08fd71 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -25,7 +25,6 @@ public static class EventListenersProvider public static void SetFactory(IEventListenersFactory factory) { - if(CustomFactory != null) throw new InvalidOperationException("EventListenersFactory can only be set once."); CustomFactory = factory; } } From f4f0f0798cd92153d4c9af7380e48ce5ad8aac3e Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 15:20:55 -0500 Subject: [PATCH 09/22] Improve collisions check --- .../src/EventHandling/EventListeners.cs | 166 +++++++++++++----- .../SappyIntegration/SappyEventListeners.cs | 24 +-- sdks/csharp/tests~/EventListenersTests.cs | 39 ++++ 3 files changed, 171 insertions(+), 58 deletions(-) create mode 100644 sdks/csharp/tests~/EventListenersTests.cs diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index ff21e08fd71..470b50ba714 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -30,32 +30,102 @@ public static void SetFactory(IEventListenersFactory factory) } public class BasicEventListeners : IEventListeners where T : Delegate + { + private DelegateIndex Listeners { get; } = new(4); + + public int Count => Listeners.Count; + + public T this[int index] => Listeners[index]; + + public void Add(T listener) + { + if (listener == null) return; + Listeners.Add(listener, listener); + } + + public void Remove(T listener) + { + if (listener == null) return; + Listeners.Remove(listener, out _); + } + } + + public class DelegateIndex where TDelegate : Delegate { private const int SmallListenerThreshold = 8; private const int CollisionBucket = -1; - private List List { get; } = new(4); + private IEqualityComparer Comparer { get; } + private List Keys { get; } + private List Values { get; } private Dictionary? Indices { get; set; } private Dictionary>? Collisions { get; set; } private Stack>? CollisionsPool { get; set; } - public int Count => List.Count; + public int Count => Values.Count; - public T this[int index] => List[index]; + public TValue this[int index] => Values[index]; - public void Add(T listener) + public DelegateIndex() : this(0) { } + + public DelegateIndex(int initialSize) : this(initialSize, EqualityComparer.Default) { } + + public DelegateIndex(int initialSize, IEqualityComparer comparer) { - if (listener == null) return; + Comparer = comparer; + Keys = new List(initialSize); + Values = new List(initialSize); + } + + public bool Add(TDelegate key, TValue value) + { + if (key == null) return false; + if (Contains(key)) return false; - var hashCode = listener.GetHashCode(); + AddUnchecked(key, value); + return true; + } + + public bool Contains(TDelegate key) + { + if (key == null || Keys.Count <= 0) return false; + + var hashCode = Comparer.GetHashCode(key); - if (List.Count <= SmallListenerThreshold) + if (Keys.Count <= SmallListenerThreshold) { - if (FindLinear(listener) >= 0) return; + return FindLinear(key) >= 0; + } + + var indices = Indices!; - List.Add(listener); + if (!indices.TryGetValue(hashCode, out var index)) return false; - if (List.Count > SmallListenerThreshold) + if (index != CollisionBucket) + { + return DelegateEquals(Keys[index], key); + } + + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + if (DelegateEquals(Keys[bucket[i]], key)) return true; + } + + return false; + } + + public void AddUnchecked(TDelegate key, TValue value) + { + var hashCode = Comparer.GetHashCode(key); + + if (Keys.Count <= SmallListenerThreshold) + { + Keys.Add(key); + Values.Add(value); + + if (Keys.Count > SmallListenerThreshold) { RebuildIndex(); } @@ -67,60 +137,58 @@ public void Add(T listener) if (!indices.TryGetValue(hashCode, out var index)) { - indices.Add(hashCode, List.Count); - List.Add(listener); + indices.Add(hashCode, Keys.Count); + Keys.Add(key); + Values.Add(value); return; } + var newIndex = Keys.Count; + Keys.Add(key); + Values.Add(value); + if (index != CollisionBucket) { - if (DelegateEquals(List[index], listener)) return; - - var newIndex = List.Count; - List.Add(listener); Collisions ??= new Dictionary>(); Collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); indices[hashCode] = CollisionBucket; return; } - var bucket = Collisions![hashCode]; - - for (var i = 0; i < bucket.Count; i++) - { - if (DelegateEquals(List[bucket[i]], listener)) return; - } - - bucket.Add(List.Count); - List.Add(listener); + Collisions![hashCode].Add(newIndex); } - public void Remove(T listener) + + public bool Remove(TDelegate key, out TValue value) { - if (listener == null || List.Count <= 0) return; + value = default!; + if (key == null || Keys.Count <= 0) return false; - var hashCode = listener.GetHashCode(); + var hashCode = Comparer.GetHashCode(key); - if (List.Count <= SmallListenerThreshold) + if (Keys.Count <= SmallListenerThreshold) { - var index = FindLinear(listener); + var index = FindLinear(key); if (index >= 0) { - List.RemoveAtSwapBack(index); + value = Values[index]; + Keys.RemoveAtSwapBack(index); + Values.RemoveAtSwapBack(index); ClearIndex(); + return true; } - return; + return false; } var indices = Indices!; - if (!indices.TryGetValue(hashCode, out var mappedIndex)) return; + if (!indices.TryGetValue(hashCode, out var mappedIndex)) return false; var removeIndex = -1; if (mappedIndex != CollisionBucket) { - if (!DelegateEquals(List[mappedIndex], listener)) return; + if (!DelegateEquals(Keys[mappedIndex], key)) return false; removeIndex = mappedIndex; indices.Remove(hashCode); @@ -132,7 +200,7 @@ public void Remove(T listener) for (var i = 0; i < bucket.Count; i++) { var candidate = bucket[i]; - if (!DelegateEquals(List[candidate], listener)) continue; + if (!DelegateEquals(Keys[candidate], key)) continue; removeIndex = candidate; RemoveBucketSlot(bucket, i); @@ -153,15 +221,17 @@ public void Remove(T listener) break; } - if (removeIndex < 0) return; + if (removeIndex < 0) return false; } - var movedFrom = List.Count - 1; - var movedHashCode = List[movedFrom].GetHashCode(); + var movedFrom = Keys.Count - 1; + var movedHashCode = Comparer.GetHashCode(Keys[movedFrom]); + value = Values[removeIndex]; - List.RemoveAtSwapBack(removeIndex); + Keys.RemoveAtSwapBack(removeIndex); + Values.RemoveAtSwapBack(removeIndex); - if (List.Count <= SmallListenerThreshold) + if (Keys.Count <= SmallListenerThreshold) { ClearIndex(); } @@ -169,13 +239,15 @@ public void Remove(T listener) { UpdateMovedIndex(movedHashCode, movedFrom, removeIndex); } + + return true; } - private int FindLinear(T listener) + private int FindLinear(TDelegate key) { - for (var i = 0; i < List.Count; i++) + for (var i = 0; i < Keys.Count; i++) { - if (DelegateEquals(List[i], listener)) return i; + if (DelegateEquals(Keys[i], key)) return i; } return -1; @@ -185,16 +257,16 @@ private void RebuildIndex() { if (Indices == null) { - Indices = new Dictionary(List.Count); + Indices = new Dictionary(Keys.Count); } else { ClearIndex(); } - for (var i = 0; i < List.Count; i++) + for (var i = 0; i < Keys.Count; i++) { - var hashCode = List[i].GetHashCode(); + var hashCode = Comparer.GetHashCode(Keys[i]); if (!Indices.TryGetValue(hashCode, out var existing)) { @@ -281,6 +353,6 @@ private void ReturnCollisionsListToPool(List list) CollisionsPool.Push(list); } - private static bool DelegateEquals(T a, T b) => EqualityComparer.Default.Equals(a, b); + private bool DelegateEquals(TDelegate a, TDelegate b) => Comparer.Equals(a, b); } } diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs index b1a9265c719..a6b669fbbb3 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -1,6 +1,5 @@ #if SAPPY using System; -using System.Collections.Generic; using Sappy; using SpacetimeDB.EventHandling; @@ -9,7 +8,7 @@ namespace SpacetimeDB.SappyIntegration public class SappyEventListeners : IEventListeners where T : Delegate { private SapDelegate Targets { get; } = new(); - private Dictionary> Cache { get; } = new(4); + private DelegateIndex> Cache { get; } = new(4); public void Add(SapTarget listener) => Targets.Add(listener); public void Remove(SapTarget listener) => Targets.Remove(listener); @@ -20,18 +19,21 @@ public class SappyEventListeners : IEventListeners where T : Delegate public void Add(T listener) { - if(listener == null) return; - var hashCode = listener.GetHashCode(); - if(!Cache.TryGetValue(hashCode, out var target)) { - target = new SapTarget(listener); - Cache.Add(hashCode, target); - } + if (listener == null) return; + if (Cache.Contains(listener)) return; + + var target = new SapTarget(listener); + Cache.AddUnchecked(listener, target); Add(target); } + public void Remove(T listener) { - if(listener == null || !Cache.TryGetValue(listener.GetHashCode(), out var target)) return; - Remove(target); + if (listener == null) return; + if (Cache.Remove(listener, out var target)) + { + Remove(target); + } } public static SappyEventListeners operator +(SappyEventListeners a, SapTarget b) @@ -56,4 +58,4 @@ public void Remove(T listener) } } } -#endif \ No newline at end of file +#endif diff --git a/sdks/csharp/tests~/EventListenersTests.cs b/sdks/csharp/tests~/EventListenersTests.cs new file mode 100644 index 00000000000..87f495509de --- /dev/null +++ b/sdks/csharp/tests~/EventListenersTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using SpacetimeDB.EventHandling; +using Xunit; + +public class EventListenersTests +{ + [Fact] + public void DelegateIndexHandlesHashCollisions() + { + var index = new DelegateIndex(0, new ConstantHashComparer()); + var listeners = new Action[12]; + + for (var i = 0; i < listeners.Length; i++) + { + var id = i; + listeners[i] = () => _ = id; + Assert.True(index.Add(listeners[i], $"listener-{i}")); + } + + Assert.False(index.Add(listeners[3], "duplicate")); + Assert.Equal(listeners.Length, index.Count); + + Assert.True(index.Remove(listeners[3], out var removed)); + Assert.Equal("listener-3", removed); + Assert.False(index.Remove(listeners[3], out _)); + + Assert.True(index.Remove(listeners[9], out removed)); + Assert.Equal("listener-9", removed); + Assert.Equal(listeners.Length - 2, index.Count); + } + + private sealed class ConstantHashComparer : IEqualityComparer + { + public bool Equals(T? x, T? y) => EqualityComparer.Default.Equals(x!, y!); + + public int GetHashCode(T obj) => 0; + } +} From 476a9f2f5414cbf033f2e9078add721d5d5d62f6 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 13 Aug 2026 10:03:46 -0500 Subject: [PATCH 10/22] Add .meta files --- sdks/csharp/src/EventHandling/EventListeners.cs | 13 ++++++++++++- sdks/csharp/src/SappyIntegration.meta | 8 ++++++++ .../SappyIntegration/SappyEventListeners.cs.meta | 11 +++++++++++ .../SappyEventListenersFactory.cs.meta | 11 +++++++++++ ...labs.spacetimedbsdk.sappyintegration.asmdef.meta | 7 +++++++ 5 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 sdks/csharp/src/SappyIntegration.meta create mode 100644 sdks/csharp/src/SappyIntegration/SappyEventListeners.cs.meta create mode 100644 sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs.meta create mode 100644 sdks/csharp/src/SappyIntegration/com.clockworklabs.spacetimedbsdk.sappyintegration.asmdef.meta diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index 470b50ba714..c749a2442c3 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace SpacetimeDB.EventHandling @@ -10,6 +10,17 @@ public interface IEventListeners where T : Delegate void Add(T listener); void Remove(T listener); + + public static IEventListeners operator +(IEventListeners a, T b) + { + a.Add(b); + return a; + } + public static IEventListeners operator -(IEventListeners a, T b) + { + a.Remove(b); + return a; + } } public interface IEventListenersFactory diff --git a/sdks/csharp/src/SappyIntegration.meta b/sdks/csharp/src/SappyIntegration.meta new file mode 100644 index 00000000000..c173c92b79c --- /dev/null +++ b/sdks/csharp/src/SappyIntegration.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c4698fe2727425488205582952273db +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs.meta b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs.meta new file mode 100644 index 00000000000..1c7ed528b41 --- /dev/null +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 670852799878403c999226dbdb5c6b39 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs.meta b/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs.meta new file mode 100644 index 00000000000..3f1a5297aa5 --- /dev/null +++ b/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a2f65d5ec304b5786ec99e70a680bb8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/src/SappyIntegration/com.clockworklabs.spacetimedbsdk.sappyintegration.asmdef.meta b/sdks/csharp/src/SappyIntegration/com.clockworklabs.spacetimedbsdk.sappyintegration.asmdef.meta new file mode 100644 index 00000000000..1aca66b0b8a --- /dev/null +++ b/sdks/csharp/src/SappyIntegration/com.clockworklabs.spacetimedbsdk.sappyintegration.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 872005274f4d4868889ff510e2bf73fc +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From e7d4dbf19e0187250999a0216bf73b6f74117de1 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 13 Aug 2026 15:16:29 -0500 Subject: [PATCH 11/22] Add Extensions --- .../csharp/src/SappyIntegration/Extensions.cs | 39 ++++++++++ .../src/SappyIntegration/Extensions.cs.meta | 11 +++ sdks/csharp/src/Table.cs | 75 +++++-------------- 3 files changed, 67 insertions(+), 58 deletions(-) create mode 100644 sdks/csharp/src/SappyIntegration/Extensions.cs create mode 100644 sdks/csharp/src/SappyIntegration/Extensions.cs.meta diff --git a/sdks/csharp/src/SappyIntegration/Extensions.cs b/sdks/csharp/src/SappyIntegration/Extensions.cs new file mode 100644 index 00000000000..9dbfd495012 --- /dev/null +++ b/sdks/csharp/src/SappyIntegration/Extensions.cs @@ -0,0 +1,39 @@ +#if SAPPY +using System; +using SpacetimeDB.EventHandling; +using Sappy; + +namespace SpacetimeDB.SappyIntegration +{ + public static class Extensions + { + public static bool AddSapTarget(this IEventListeners listeners, SapTarget value) where T : Delegate + { + if (listeners is not SappyEventListeners sappyEventListeners) + { + throw new InvalidOperationException( + "Cannot add a SapTarget because this listener collection is not backed by Sappy. " + + "Ensure the Sappy integration assembly registered before this table handle was created." + ); + } + + sappyEventListeners.Add(value); + return true; + } + + public static bool RemoveSapTarget(this IEventListeners listeners, SapTarget value) where T : Delegate + { + if (listeners is not SappyEventListeners sappyEventListeners) + { + throw new InvalidOperationException( + "Cannot remove a SapTarget because this listener collection is not backed by Sappy. " + + "Ensure the Sappy integration assembly registered before this table handle was created." + ); + } + + sappyEventListeners.Remove(value); + return true; + } + } +} +#endif diff --git a/sdks/csharp/src/SappyIntegration/Extensions.cs.meta b/sdks/csharp/src/SappyIntegration/Extensions.cs.meta new file mode 100644 index 00000000000..859a0f2071b --- /dev/null +++ b/sdks/csharp/src/SappyIntegration/Extensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e9e0bd96416e4b9eb254e5303f468e9f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 31452edc441..38b03f08367 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -397,21 +397,17 @@ void IRemoteTableHandle.Parse(TableUpdate update, ParsedDatabaseUpdate dbOps) } public delegate void RowEventHandler(EventContext context, Row row); + public delegate void UpdateEventHandler(EventContext context, Row oldRow, Row newRow); + private CustomRowEventHandler OnInsertHandler { get; } = new(); -#if SAPPY - public IEventListeners OnInsert - { - get => OnInsertHandler.Listeners; - set => OnInsertHandler.Listeners = value; - } -#else public event RowEventHandler OnInsert { add => OnInsertHandler.Listeners.Add(value); remove => OnInsertHandler.Listeners.Remove(value); } +#if SAPPY + public IEventListeners OnInsertListeners => OnInsertHandler.Listeners; #endif - public delegate void UpdateEventHandler(EventContext context, Row oldRow, Row newRow); public int Count => (int)Entries.CountDistinct; @@ -575,49 +571,25 @@ void IRemoteTableHandle.PostApply(IEventContext context) protected class CustomRowEventHandler { - private IEventListeners _listeners = EventListenersProvider.Create(); - public IEventListeners Listeners - { - get => _listeners; - set - { - if (_listeners != null && value != _listeners) - { - throw new InvalidOperationException("You can't override the targets of a SapStem."); - } - _listeners = value; - } - } + public IEventListeners Listeners { get; } = EventListenersProvider.Create(); public void Invoke(EventContext ctx, Row row) { for (var i = Listeners.Count - 1; i >= 0; i--) { - _listeners[i]?.Invoke(ctx, row); + Listeners[i].Invoke(ctx, row); } } } protected class CustomUpdateEventHandler { - private IEventListeners _listeners = EventListenersProvider.Create(); - public IEventListeners Listeners - { - get => _listeners; - set - { - if (_listeners != null && value != _listeners) - { - throw new InvalidOperationException("You can't override the targets of a SapStem."); - } - _listeners = value; - } - } + public IEventListeners Listeners { get; } = EventListenersProvider.Create(); public void Invoke(EventContext ctx, Row oldRow, Row newRow) { - for (var i = _listeners.Count - 1; i >= 0; i--) + for (var i = Listeners.Count - 1; i >= 0; i--) { - _listeners[i]?.Invoke(ctx, oldRow, newRow); + Listeners[i].Invoke(ctx, oldRow, newRow); } } } @@ -633,46 +605,33 @@ public abstract class RemoteTableHandle : RemoteTableHandleBa protected RemoteTableHandle(IDbConnection conn) : base(conn) { } private CustomRowEventHandler OnDeleteHandler { get; } = new(); -#if SAPPY - public IEventListeners OnDelete - { - get => OnDeleteHandler.Listeners; - set => OnDeleteHandler.Listeners = value; - } -#else public event RowEventHandler OnDelete { add => OnDeleteHandler.Listeners.Add(value); remove => OnDeleteHandler.Listeners.Remove(value); } +#if SAPPY + public IEventListeners OnDeleteListeners => OnDeleteHandler.Listeners; #endif + private CustomRowEventHandler OnBeforeDeleteHandler { get; } = new(); -#if SAPPY - public IEventListeners OnBeforeDelete - { - get => OnBeforeDeleteHandler.Listeners; - set => OnBeforeDeleteHandler.Listeners = value; - } -#else public event RowEventHandler OnBeforeDelete { add => OnBeforeDeleteHandler.Listeners.Add(value); remove => OnBeforeDeleteHandler.Listeners.Remove(value); } +#if SAPPY + public IEventListeners OnBeforeDeleteListeners => OnBeforeDeleteHandler.Listeners; #endif + private CustomUpdateEventHandler OnUpdateHandler { get; } = new(); -#if SAPPY - public IEventListeners OnUpdate - { - get => OnUpdateHandler.Listeners; - set => OnUpdateHandler.Listeners = value; - } -#else public event UpdateEventHandler OnUpdate { add => OnUpdateHandler.Listeners.Add(value); remove => OnUpdateHandler.Listeners.Remove(value); } +#if SAPPY + public IEventListeners OnUpdateListeners => OnUpdateHandler.Listeners; #endif protected override void InvokeDelete(IEventContext context, IStructuralReadWrite row) From d82ad5b5342da14b5f0af1de8037311769e270ee Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 09:22:50 -0500 Subject: [PATCH 12/22] Performance improvements suggested by Codex --- .../src/EventHandling/EventListeners.cs | 57 ++++++++++++++++++- .../SappyIntegration/SappyEventListeners.cs | 9 ++- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index c749a2442c3..e0ced970958 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -51,7 +51,7 @@ public class BasicEventListeners : IEventListeners where T : Delegate public void Add(T listener) { if (listener == null) return; - Listeners.Add(listener, listener); + Listeners.Add(listener, listener, static (_, listener) => listener); } public void Remove(T listener) @@ -90,10 +90,57 @@ public DelegateIndex(int initialSize, IEqualityComparer comparer) public bool Add(TDelegate key, TValue value) { + return Add(key, value, static (_, value) => value, out _); + } + + public bool Add(TDelegate key, TState state, Func createValue) + { + return Add(key, state, createValue, out _); + } + + public bool Add(TDelegate key, TState state, Func createValue, out TValue value) + { + value = default!; if (key == null) return false; - if (Contains(key)) return false; - AddUnchecked(key, value); + var hashCode = Comparer.GetHashCode(key); + + if (Keys.Count <= SmallListenerThreshold) + { + if (FindLinear(key) >= 0) return false; + + value = createValue(key, state); + AddUnchecked(key, value, hashCode); + return true; + } + + var indices = Indices!; + + if (!indices.TryGetValue(hashCode, out var index)) + { + value = createValue(key, state); + AddUnchecked(key, value, hashCode); + return true; + } + + if (index != CollisionBucket) + { + if (DelegateEquals(Keys[index], key)) return false; + + value = createValue(key, state); + AddUnchecked(key, value, hashCode); + return true; + } + + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + if (DelegateEquals(Keys[bucket[i]], key)) return false; + } + + value = createValue(key, state); + AddUnchecked(key, value, hashCode); return true; } @@ -130,7 +177,11 @@ public bool Contains(TDelegate key) public void AddUnchecked(TDelegate key, TValue value) { var hashCode = Comparer.GetHashCode(key); + AddUnchecked(key, value, hashCode); + } + private void AddUnchecked(TDelegate key, TValue value, int hashCode) + { if (Keys.Count <= SmallListenerThreshold) { Keys.Add(key); diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs index a6b669fbbb3..f1ce869208a 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -20,11 +20,10 @@ public class SappyEventListeners : IEventListeners where T : Delegate public void Add(T listener) { if (listener == null) return; - if (Cache.Contains(listener)) return; - - var target = new SapTarget(listener); - Cache.AddUnchecked(listener, target); - Add(target); + if (Cache.Add(listener, this, static (listener, _) => new SapTarget(listener), out var target)) + { + Add(target); + } } public void Remove(T listener) From 662572e4fc1470b0e8eccfb1ebbf69a0fe7ba8e7 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 12:51:14 -0500 Subject: [PATCH 13/22] Cache method delegates --- .../src/EventHandling/EventListeners.cs | 17 ++++--------- .../SappyIntegration/SappyEventListeners.cs | 25 +++---------------- 2 files changed, 9 insertions(+), 33 deletions(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index e0ced970958..19615171370 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -10,17 +10,6 @@ public interface IEventListeners where T : Delegate void Add(T listener); void Remove(T listener); - - public static IEventListeners operator +(IEventListeners a, T b) - { - a.Add(b); - return a; - } - public static IEventListeners operator -(IEventListeners a, T b) - { - a.Remove(b); - return a; - } } public interface IEventListenersFactory @@ -42,6 +31,8 @@ public static void SetFactory(IEventListenersFactory factory) public class BasicEventListeners : IEventListeners where T : Delegate { + private static Func CreateValue { get; } = CreateValueFromListener; + private DelegateIndex Listeners { get; } = new(4); public int Count => Listeners.Count; @@ -51,7 +42,7 @@ public class BasicEventListeners : IEventListeners where T : Delegate public void Add(T listener) { if (listener == null) return; - Listeners.Add(listener, listener, static (_, listener) => listener); + Listeners.Add(listener, listener, CreateValue); } public void Remove(T listener) @@ -59,6 +50,8 @@ public void Remove(T listener) if (listener == null) return; Listeners.Remove(listener, out _); } + + private static T CreateValueFromListener(T _, T listener) => listener; } public class DelegateIndex where TDelegate : Delegate diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs index f1ce869208a..73ada1d9002 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -7,6 +7,8 @@ namespace SpacetimeDB.SappyIntegration { public class SappyEventListeners : IEventListeners where T : Delegate { + private static Func, SapTarget> CreateTarget { get; } = CreateTargetFromListener; + private SapDelegate Targets { get; } = new(); private DelegateIndex> Cache { get; } = new(4); @@ -20,7 +22,7 @@ public class SappyEventListeners : IEventListeners where T : Delegate public void Add(T listener) { if (listener == null) return; - if (Cache.Add(listener, this, static (listener, _) => new SapTarget(listener), out var target)) + if (Cache.Add(listener, this, CreateTarget, out var target)) { Add(target); } @@ -35,26 +37,7 @@ public void Remove(T listener) } } - public static SappyEventListeners operator +(SappyEventListeners a, SapTarget b) - { - a.Add(b); - return a; - } - public static SappyEventListeners operator -(SappyEventListeners a, SapTarget b) - { - a.Remove(b); - return a; - } - public static SappyEventListeners operator +(SappyEventListeners a, T b) - { - a.Add(b); - return a; - } - public static SappyEventListeners operator -(SappyEventListeners a, T b) - { - a.Remove(b); - return a; - } + private static SapTarget CreateTargetFromListener(T listener, SappyEventListeners _) => new(listener); } } #endif From 72f1946d7b4bb89d49be579a6c431f726cae4c6c Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 14:07:48 -0500 Subject: [PATCH 14/22] Improve performance --- .../src/EventHandling/EventListeners.cs | 119 ++++++++++++------ 1 file changed, 78 insertions(+), 41 deletions(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index 19615171370..40393e87727 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -60,15 +60,18 @@ public class DelegateIndex where TDelegate : Delegate private const int CollisionBucket = -1; private IEqualityComparer Comparer { get; } - private List Keys { get; } - private List Values { get; } + private int[] Hashes; + private TDelegate?[] Keys; + private TValue?[] Values; + private int Capacity { get; set; } + private int CountValue { get; set; } private Dictionary? Indices { get; set; } private Dictionary>? Collisions { get; set; } private Stack>? CollisionsPool { get; set; } - public int Count => Values.Count; + public int Count => CountValue; - public TValue this[int index] => Values[index]; + public TValue this[int index] => Values[index]!; public DelegateIndex() : this(0) { } @@ -77,8 +80,10 @@ public DelegateIndex(int initialSize) : this(initialSize, EqualityComparer comparer) { Comparer = comparer; - Keys = new List(initialSize); - Values = new List(initialSize); + Capacity = Math.Max(1, initialSize); + Hashes = new int[Capacity]; + Keys = new TDelegate[Capacity]; + Values = new TValue[Capacity]; } public bool Add(TDelegate key, TValue value) @@ -98,7 +103,7 @@ public bool Add(TDelegate key, TState state, Func= 0) return false; @@ -118,7 +123,7 @@ public bool Add(TDelegate key, TState state, Func(TDelegate key, TState state, Func(TDelegate key, TState state, Func= 0; } @@ -154,14 +159,14 @@ public bool Contains(TDelegate key) if (index != CollisionBucket) { - return DelegateEquals(Keys[index], key); + return DelegateEquals(Keys[index]!, key); } var bucket = Collisions![hashCode]; for (var i = 0; i < bucket.Count; i++) { - if (DelegateEquals(Keys[bucket[i]], key)) return true; + if (DelegateEquals(Keys[bucket[i]]!, key)) return true; } return false; @@ -175,12 +180,11 @@ public void AddUnchecked(TDelegate key, TValue value) private void AddUnchecked(TDelegate key, TValue value, int hashCode) { - if (Keys.Count <= SmallListenerThreshold) + if (CountValue <= SmallListenerThreshold) { - Keys.Add(key); - Values.Add(value); + AddRaw(key, value, hashCode); - if (Keys.Count > SmallListenerThreshold) + if (CountValue > SmallListenerThreshold) { RebuildIndex(); } @@ -192,15 +196,11 @@ private void AddUnchecked(TDelegate key, TValue value, int hashCode) if (!indices.TryGetValue(hashCode, out var index)) { - indices.Add(hashCode, Keys.Count); - Keys.Add(key); - Values.Add(value); + indices.Add(hashCode, AddRaw(key, value, hashCode)); return; } - var newIndex = Keys.Count; - Keys.Add(key); - Values.Add(value); + var newIndex = AddRaw(key, value, hashCode); if (index != CollisionBucket) { @@ -216,18 +216,17 @@ private void AddUnchecked(TDelegate key, TValue value, int hashCode) public bool Remove(TDelegate key, out TValue value) { value = default!; - if (key == null || Keys.Count <= 0) return false; + if (key == null || CountValue <= 0) return false; var hashCode = Comparer.GetHashCode(key); - if (Keys.Count <= SmallListenerThreshold) + if (CountValue <= SmallListenerThreshold) { var index = FindLinear(key); if (index >= 0) { - value = Values[index]; - Keys.RemoveAtSwapBack(index); - Values.RemoveAtSwapBack(index); + value = Values[index]!; + RemoveAtSwapBackRaw(index); ClearIndex(); return true; } @@ -243,7 +242,7 @@ public bool Remove(TDelegate key, out TValue value) if (mappedIndex != CollisionBucket) { - if (!DelegateEquals(Keys[mappedIndex], key)) return false; + if (!DelegateEquals(Keys[mappedIndex]!, key)) return false; removeIndex = mappedIndex; indices.Remove(hashCode); @@ -255,7 +254,7 @@ public bool Remove(TDelegate key, out TValue value) for (var i = 0; i < bucket.Count; i++) { var candidate = bucket[i]; - if (!DelegateEquals(Keys[candidate], key)) continue; + if (!DelegateEquals(Keys[candidate]!, key)) continue; removeIndex = candidate; RemoveBucketSlot(bucket, i); @@ -279,14 +278,13 @@ public bool Remove(TDelegate key, out TValue value) if (removeIndex < 0) return false; } - var movedFrom = Keys.Count - 1; - var movedHashCode = Comparer.GetHashCode(Keys[movedFrom]); - value = Values[removeIndex]; + var movedFrom = CountValue - 1; + var movedHashCode = Hashes[movedFrom]; + value = Values[removeIndex]!; - Keys.RemoveAtSwapBack(removeIndex); - Values.RemoveAtSwapBack(removeIndex); + RemoveAtSwapBackRaw(removeIndex); - if (Keys.Count <= SmallListenerThreshold) + if (CountValue <= SmallListenerThreshold) { ClearIndex(); } @@ -300,28 +298,67 @@ public bool Remove(TDelegate key, out TValue value) private int FindLinear(TDelegate key) { - for (var i = 0; i < Keys.Count; i++) + for (var i = 0; i < CountValue; i++) { - if (DelegateEquals(Keys[i], key)) return i; + if (DelegateEquals(Keys[i]!, key)) return i; } return -1; } + private int AddRaw(TDelegate key, TValue value, int hashCode) + { + EnsureCapacity(); + + var index = CountValue; + Hashes[index] = hashCode; + Keys[index] = key; + Values[index] = value; + CountValue++; + return index; + } + + private void RemoveAtSwapBackRaw(int index) + { + var lastIndex = CountValue - 1; + + if (index != lastIndex) + { + Hashes[index] = Hashes[lastIndex]; + Keys[index] = Keys[lastIndex]; + Values[index] = Values[lastIndex]; + } + + Hashes[lastIndex] = 0; + Keys[lastIndex] = null; + Values[lastIndex] = default; + CountValue = lastIndex; + } + + private void EnsureCapacity() + { + if (CountValue < Capacity) return; + + Capacity *= 2; + Array.Resize(ref Hashes, Capacity); + Array.Resize(ref Keys, Capacity); + Array.Resize(ref Values, Capacity); + } + private void RebuildIndex() { if (Indices == null) { - Indices = new Dictionary(Keys.Count); + Indices = new Dictionary(Capacity); } else { ClearIndex(); } - for (var i = 0; i < Keys.Count; i++) + for (var i = 0; i < CountValue; i++) { - var hashCode = Comparer.GetHashCode(Keys[i]); + var hashCode = Hashes[i]; if (!Indices.TryGetValue(hashCode, out var existing)) { From ec1928cdf5fcec2681aeeab7ec7a641aa160aad8 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 14:54:30 -0500 Subject: [PATCH 15/22] Remove DelegateIndex --- .../src/EventHandling/EventListeners.cs | 197 +++-------- .../SappyIntegration/SappyEventListeners.cs | 319 +++++++++++++++++- sdks/csharp/tests~/EventListenersTests.cs | 57 +++- 3 files changed, 393 insertions(+), 180 deletions(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index 40393e87727..a0682ffa3be 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -30,39 +30,12 @@ public static void SetFactory(IEventListenersFactory factory) } public class BasicEventListeners : IEventListeners where T : Delegate - { - private static Func CreateValue { get; } = CreateValueFromListener; - - private DelegateIndex Listeners { get; } = new(4); - - public int Count => Listeners.Count; - - public T this[int index] => Listeners[index]; - - public void Add(T listener) - { - if (listener == null) return; - Listeners.Add(listener, listener, CreateValue); - } - - public void Remove(T listener) - { - if (listener == null) return; - Listeners.Remove(listener, out _); - } - - private static T CreateValueFromListener(T _, T listener) => listener; - } - - public class DelegateIndex where TDelegate : Delegate { private const int SmallListenerThreshold = 8; private const int CollisionBucket = -1; - private IEqualityComparer Comparer { get; } private int[] Hashes; - private TDelegate?[] Keys; - private TValue?[] Values; + private T?[] Listeners; private int Capacity { get; set; } private int CountValue { get; set; } private Dictionary? Indices { get; set; } @@ -71,118 +44,28 @@ public class DelegateIndex where TDelegate : Delegate public int Count => CountValue; - public TValue this[int index] => Values[index]!; + public T this[int index] => Listeners[index]!; - public DelegateIndex() : this(0) { } + public BasicEventListeners() : this(4) { } - public DelegateIndex(int initialSize) : this(initialSize, EqualityComparer.Default) { } - - public DelegateIndex(int initialSize, IEqualityComparer comparer) + public BasicEventListeners(int initialSize) { - Comparer = comparer; Capacity = Math.Max(1, initialSize); Hashes = new int[Capacity]; - Keys = new TDelegate[Capacity]; - Values = new TValue[Capacity]; - } - - public bool Add(TDelegate key, TValue value) - { - return Add(key, value, static (_, value) => value, out _); - } - - public bool Add(TDelegate key, TState state, Func createValue) - { - return Add(key, state, createValue, out _); - } - - public bool Add(TDelegate key, TState state, Func createValue, out TValue value) - { - value = default!; - if (key == null) return false; - - var hashCode = Comparer.GetHashCode(key); - - if (CountValue <= SmallListenerThreshold) - { - if (FindLinear(key) >= 0) return false; - - value = createValue(key, state); - AddUnchecked(key, value, hashCode); - return true; - } - - var indices = Indices!; - - if (!indices.TryGetValue(hashCode, out var index)) - { - value = createValue(key, state); - AddUnchecked(key, value, hashCode); - return true; - } - - if (index != CollisionBucket) - { - if (DelegateEquals(Keys[index]!, key)) return false; - - value = createValue(key, state); - AddUnchecked(key, value, hashCode); - return true; - } - - var bucket = Collisions![hashCode]; - - for (var i = 0; i < bucket.Count; i++) - { - if (DelegateEquals(Keys[bucket[i]]!, key)) return false; - } - - value = createValue(key, state); - AddUnchecked(key, value, hashCode); - return true; + Listeners = new T[Capacity]; } - public bool Contains(TDelegate key) + public void Add(T listener) { - if (key == null || CountValue <= 0) return false; + if (listener == null) return; - var hashCode = Comparer.GetHashCode(key); + var hashCode = listener.GetHashCode(); if (CountValue <= SmallListenerThreshold) { - return FindLinear(key) >= 0; - } - - var indices = Indices!; - - if (!indices.TryGetValue(hashCode, out var index)) return false; - - if (index != CollisionBucket) - { - return DelegateEquals(Keys[index]!, key); - } + if (FindLinear(listener) >= 0) return; - var bucket = Collisions![hashCode]; - - for (var i = 0; i < bucket.Count; i++) - { - if (DelegateEquals(Keys[bucket[i]]!, key)) return true; - } - - return false; - } - - public void AddUnchecked(TDelegate key, TValue value) - { - var hashCode = Comparer.GetHashCode(key); - AddUnchecked(key, value, hashCode); - } - - private void AddUnchecked(TDelegate key, TValue value, int hashCode) - { - if (CountValue <= SmallListenerThreshold) - { - AddRaw(key, value, hashCode); + AddRaw(hashCode, listener); if (CountValue > SmallListenerThreshold) { @@ -196,53 +79,58 @@ private void AddUnchecked(TDelegate key, TValue value, int hashCode) if (!indices.TryGetValue(hashCode, out var index)) { - indices.Add(hashCode, AddRaw(key, value, hashCode)); + indices.Add(hashCode, AddRaw(hashCode, listener)); return; } - var newIndex = AddRaw(key, value, hashCode); - if (index != CollisionBucket) { + if (DelegateEquals(Listeners[index]!, listener)) return; + + var newIndex = AddRaw(hashCode, listener); Collisions ??= new Dictionary>(); Collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); indices[hashCode] = CollisionBucket; return; } - Collisions![hashCode].Add(newIndex); + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + if (DelegateEquals(Listeners[bucket[i]]!, listener)) return; + } + + bucket.Add(AddRaw(hashCode, listener)); } - public bool Remove(TDelegate key, out TValue value) + public void Remove(T listener) { - value = default!; - if (key == null || CountValue <= 0) return false; + if (listener == null || CountValue <= 0) return; - var hashCode = Comparer.GetHashCode(key); + var hashCode = listener.GetHashCode(); if (CountValue <= SmallListenerThreshold) { - var index = FindLinear(key); + var index = FindLinear(listener); if (index >= 0) { - value = Values[index]!; RemoveAtSwapBackRaw(index); ClearIndex(); - return true; } - return false; + return; } var indices = Indices!; - if (!indices.TryGetValue(hashCode, out var mappedIndex)) return false; + if (!indices.TryGetValue(hashCode, out var mappedIndex)) return; var removeIndex = -1; if (mappedIndex != CollisionBucket) { - if (!DelegateEquals(Keys[mappedIndex]!, key)) return false; + if (!DelegateEquals(Listeners[mappedIndex]!, listener)) return; removeIndex = mappedIndex; indices.Remove(hashCode); @@ -254,7 +142,7 @@ public bool Remove(TDelegate key, out TValue value) for (var i = 0; i < bucket.Count; i++) { var candidate = bucket[i]; - if (!DelegateEquals(Keys[candidate]!, key)) continue; + if (!DelegateEquals(Listeners[candidate]!, listener)) continue; removeIndex = candidate; RemoveBucketSlot(bucket, i); @@ -275,12 +163,11 @@ public bool Remove(TDelegate key, out TValue value) break; } - if (removeIndex < 0) return false; + if (removeIndex < 0) return; } var movedFrom = CountValue - 1; var movedHashCode = Hashes[movedFrom]; - value = Values[removeIndex]!; RemoveAtSwapBackRaw(removeIndex); @@ -292,28 +179,25 @@ public bool Remove(TDelegate key, out TValue value) { UpdateMovedIndex(movedHashCode, movedFrom, removeIndex); } - - return true; } - private int FindLinear(TDelegate key) + private int FindLinear(T listener) { for (var i = 0; i < CountValue; i++) { - if (DelegateEquals(Keys[i]!, key)) return i; + if (DelegateEquals(Listeners[i]!, listener)) return i; } return -1; } - private int AddRaw(TDelegate key, TValue value, int hashCode) + private int AddRaw(int hashCode, T listener) { EnsureCapacity(); var index = CountValue; Hashes[index] = hashCode; - Keys[index] = key; - Values[index] = value; + Listeners[index] = listener; CountValue++; return index; } @@ -325,13 +209,11 @@ private void RemoveAtSwapBackRaw(int index) if (index != lastIndex) { Hashes[index] = Hashes[lastIndex]; - Keys[index] = Keys[lastIndex]; - Values[index] = Values[lastIndex]; + Listeners[index] = Listeners[lastIndex]; } Hashes[lastIndex] = 0; - Keys[lastIndex] = null; - Values[lastIndex] = default; + Listeners[lastIndex] = null; CountValue = lastIndex; } @@ -341,8 +223,7 @@ private void EnsureCapacity() Capacity *= 2; Array.Resize(ref Hashes, Capacity); - Array.Resize(ref Keys, Capacity); - Array.Resize(ref Values, Capacity); + Array.Resize(ref Listeners, Capacity); } private void RebuildIndex() @@ -445,6 +326,6 @@ private void ReturnCollisionsListToPool(List list) CollisionsPool.Push(list); } - private bool DelegateEquals(TDelegate a, TDelegate b) => Comparer.Equals(a, b); + private static bool DelegateEquals(T a, T b) => EqualityComparer.Default.Equals(a, b); } } diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs index 73ada1d9002..e073b2407fb 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -1,5 +1,6 @@ #if SAPPY using System; +using System.Collections.Generic; using Sappy; using SpacetimeDB.EventHandling; @@ -7,10 +8,8 @@ namespace SpacetimeDB.SappyIntegration { public class SappyEventListeners : IEventListeners where T : Delegate { - private static Func, SapTarget> CreateTarget { get; } = CreateTargetFromListener; - private SapDelegate Targets { get; } = new(); - private DelegateIndex> Cache { get; } = new(4); + private TargetCache Cache { get; } = new(4); public void Add(SapTarget listener) => Targets.Add(listener); public void Remove(SapTarget listener) => Targets.Remove(listener); @@ -22,7 +21,7 @@ public class SappyEventListeners : IEventListeners where T : Delegate public void Add(T listener) { if (listener == null) return; - if (Cache.Add(listener, this, CreateTarget, out var target)) + if (Cache.Add(listener, out var target)) { Add(target); } @@ -37,7 +36,317 @@ public void Remove(T listener) } } - private static SapTarget CreateTargetFromListener(T listener, SappyEventListeners _) => new(listener); + private sealed class TargetCache + { + private const int SmallListenerThreshold = 8; + private const int CollisionBucket = -1; + + private int[] Hashes; + private T?[] Callbacks; + private SapTarget?[] Targets; + private int Capacity { get; set; } + private int Count { get; set; } + private Dictionary? Indices { get; set; } + private Dictionary>? Collisions { get; set; } + private Stack>? CollisionsPool { get; set; } + + public TargetCache(int capacity) + { + Capacity = Math.Max(1, capacity); + Hashes = new int[Capacity]; + Callbacks = new T[Capacity]; + Targets = new SapTarget[Capacity]; + } + + public bool Add(T callback, out SapTarget target) + { + target = null!; + if (callback == null) return false; + + var hashCode = callback.GetHashCode(); + + if (Count <= SmallListenerThreshold) + { + if (FindLinear(callback) >= 0) return false; + + target = new SapTarget(callback); + AddRaw(hashCode, callback, target); + + if (Count > SmallListenerThreshold) + { + RebuildIndex(); + } + + return true; + } + + var indices = Indices!; + + if (!indices.TryGetValue(hashCode, out var index)) + { + target = new SapTarget(callback); + indices.Add(hashCode, AddRaw(hashCode, callback, target)); + return true; + } + + if (index != CollisionBucket) + { + if (DelegateEquals(Callbacks[index]!, callback)) return false; + + target = new SapTarget(callback); + var newIndex = AddRaw(hashCode, callback, target); + Collisions ??= new Dictionary>(); + Collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); + indices[hashCode] = CollisionBucket; + return true; + } + + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + if (DelegateEquals(Callbacks[bucket[i]]!, callback)) return false; + } + + target = new SapTarget(callback); + bucket.Add(AddRaw(hashCode, callback, target)); + return true; + } + + public bool Remove(T callback, out SapTarget target) + { + target = null!; + if (callback == null || Count <= 0) return false; + + var hashCode = callback.GetHashCode(); + + if (Count <= SmallListenerThreshold) + { + var index = FindLinear(callback); + if (index >= 0) + { + target = Targets[index]!; + RemoveAtSwapBackRaw(index); + ClearIndex(); + return true; + } + + return false; + } + + var indices = Indices!; + + if (!indices.TryGetValue(hashCode, out var mappedIndex)) return false; + + var removeIndex = -1; + + if (mappedIndex != CollisionBucket) + { + if (!DelegateEquals(Callbacks[mappedIndex]!, callback)) return false; + + removeIndex = mappedIndex; + indices.Remove(hashCode); + } + else + { + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + var candidate = bucket[i]; + if (!DelegateEquals(Callbacks[candidate]!, callback)) continue; + + removeIndex = candidate; + RemoveBucketSlot(bucket, i); + + if (bucket.Count == 1) + { + indices[hashCode] = bucket[0]; + Collisions.Remove(hashCode); + ReturnCollisionsListToPool(bucket); + } + else if (bucket.Count == 0) + { + indices.Remove(hashCode); + Collisions.Remove(hashCode); + ReturnCollisionsListToPool(bucket); + } + + break; + } + + if (removeIndex < 0) return false; + } + + var movedFrom = Count - 1; + var movedHashCode = Hashes[movedFrom]; + target = Targets[removeIndex]!; + + RemoveAtSwapBackRaw(removeIndex); + + if (Count <= SmallListenerThreshold) + { + ClearIndex(); + } + else if (removeIndex != movedFrom) + { + UpdateMovedIndex(movedHashCode, movedFrom, removeIndex); + } + + return true; + } + + private int FindLinear(T callback) + { + for (var i = 0; i < Count; i++) + { + if (DelegateEquals(Callbacks[i]!, callback)) return i; + } + + return -1; + } + + private int AddRaw(int hashCode, T callback, SapTarget target) + { + EnsureCapacity(); + + var index = Count; + Hashes[index] = hashCode; + Callbacks[index] = callback; + Targets[index] = target; + Count++; + return index; + } + + private void RemoveAtSwapBackRaw(int index) + { + var lastIndex = Count - 1; + + if (index != lastIndex) + { + Hashes[index] = Hashes[lastIndex]; + Callbacks[index] = Callbacks[lastIndex]; + Targets[index] = Targets[lastIndex]; + } + + Hashes[lastIndex] = 0; + Callbacks[lastIndex] = null; + Targets[lastIndex] = null; + Count = lastIndex; + } + + private void EnsureCapacity() + { + if (Count < Capacity) return; + + Capacity *= 2; + Array.Resize(ref Hashes, Capacity); + Array.Resize(ref Callbacks, Capacity); + Array.Resize(ref Targets, Capacity); + } + + private void RebuildIndex() + { + if (Indices == null) + { + Indices = new Dictionary(Capacity); + } + else + { + ClearIndex(); + } + + for (var i = 0; i < Count; i++) + { + var hashCode = Hashes[i]; + + if (!Indices.TryGetValue(hashCode, out var existing)) + { + Indices.Add(hashCode, i); + continue; + } + + Collisions ??= new Dictionary>(); + + if (existing != CollisionBucket) + { + Collisions[hashCode] = GetCollisionsListFromPool(existing, i); + Indices[hashCode] = CollisionBucket; + } + else + { + Collisions[hashCode].Add(i); + } + } + } + + private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) + { + var mappedIndex = Indices![hashCode]; + + if (mappedIndex != CollisionBucket) + { + Indices[hashCode] = newIndex; + return; + } + + var bucket = Collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + if (bucket[i] == oldIndex) + { + bucket[i] = newIndex; + return; + } + } + } + + private static void RemoveBucketSlot(List bucket, int slot) + { + var lastSlot = bucket.Count - 1; + + if (slot != lastSlot) + { + bucket[slot] = bucket[lastSlot]; + } + + bucket.RemoveAt(lastSlot); + } + + private void ClearIndex() + { + Indices?.Clear(); + + if (Collisions == null) return; + + foreach (var collisions in Collisions.Values) + { + ReturnCollisionsListToPool(collisions); + } + + Collisions.Clear(); + } + + private List GetCollisionsListFromPool(int a, int b) + { + if (CollisionsPool == null || CollisionsPool.Count <= 0) return new List(2) { a, b }; + + var list = CollisionsPool.Pop(); + list.Add(a); + list.Add(b); + return list; + } + + private void ReturnCollisionsListToPool(List list) + { + list.Clear(); + CollisionsPool ??= new Stack>(); + CollisionsPool.Push(list); + } + + private static bool DelegateEquals(T a, T b) => EqualityComparer.Default.Equals(a, b); + } } } #endif diff --git a/sdks/csharp/tests~/EventListenersTests.cs b/sdks/csharp/tests~/EventListenersTests.cs index 87f495509de..f28d3dc424f 100644 --- a/sdks/csharp/tests~/EventListenersTests.cs +++ b/sdks/csharp/tests~/EventListenersTests.cs @@ -1,39 +1,62 @@ using System; -using System.Collections.Generic; using SpacetimeDB.EventHandling; using Xunit; public class EventListenersTests { [Fact] - public void DelegateIndexHandlesHashCollisions() + public void BasicEventListenersDeduplicatesRemovesAndResubscribes() { - var index = new DelegateIndex(0, new ConstantHashComparer()); + var eventListeners = new BasicEventListeners(); + var callCount = 0; var listeners = new Action[12]; for (var i = 0; i < listeners.Length; i++) { - var id = i; - listeners[i] = () => _ = id; - Assert.True(index.Add(listeners[i], $"listener-{i}")); + listeners[i] = new Listener(() => callCount++).Invoke; + eventListeners.Add(listeners[i]); } - Assert.False(index.Add(listeners[3], "duplicate")); - Assert.Equal(listeners.Length, index.Count); + eventListeners.Add(listeners[3]); + Assert.Equal(listeners.Length, eventListeners.Count); - Assert.True(index.Remove(listeners[3], out var removed)); - Assert.Equal("listener-3", removed); - Assert.False(index.Remove(listeners[3], out _)); + InvokeAll(eventListeners); + Assert.Equal(listeners.Length, callCount); - Assert.True(index.Remove(listeners[9], out removed)); - Assert.Equal("listener-9", removed); - Assert.Equal(listeners.Length - 2, index.Count); + eventListeners.Remove(listeners[3]); + eventListeners.Remove(listeners[9]); + eventListeners.Remove(listeners[3]); + Assert.Equal(listeners.Length - 2, eventListeners.Count); + + callCount = 0; + InvokeAll(eventListeners); + Assert.Equal(listeners.Length - 2, callCount); + + eventListeners.Add(listeners[3]); + eventListeners.Add(listeners[9]); + Assert.Equal(listeners.Length, eventListeners.Count); + } + + private static void InvokeAll(BasicEventListeners listeners) + { + for (var i = listeners.Count - 1; i >= 0; i--) + { + listeners[i](); + } } - private sealed class ConstantHashComparer : IEqualityComparer + private sealed class Listener { - public bool Equals(T? x, T? y) => EqualityComparer.Default.Equals(x!, y!); + private readonly Action Callback; + + public Listener(Action callback) + { + Callback = callback; + } - public int GetHashCode(T obj) => 0; + public void Invoke() + { + Callback(); + } } } From 4be5203cc18bb1d410c18c47ba69d9d36ed486b7 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 15:15:59 -0500 Subject: [PATCH 16/22] Squeeze more performance --- .../src/EventHandling/EventListeners.cs | 150 +++++----- .../SappyIntegration/SappyEventListeners.cs | 256 +++++++++++------- sdks/csharp/src/Table.cs | 10 +- 3 files changed, 246 insertions(+), 170 deletions(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index a0682ffa3be..58fbc21e9ce 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; namespace SpacetimeDB.EventHandling { @@ -34,25 +35,27 @@ public class BasicEventListeners : IEventListeners where T : Delegate private const int SmallListenerThreshold = 8; private const int CollisionBucket = -1; - private int[] Hashes; - private T?[] Listeners; - private int Capacity { get; set; } - private int CountValue { get; set; } - private Dictionary? Indices { get; set; } - private Dictionary>? Collisions { get; set; } - private Stack>? CollisionsPool { get; set; } + private static readonly EqualityComparer Comparer = EqualityComparer.Default; - public int Count => CountValue; + private int[] _hashes; + private T?[] _listeners; + private int _capacity; + private int _count; + private Dictionary? _indices; + private Dictionary>? _collisions; + private Stack>? _collisionsPool; - public T this[int index] => Listeners[index]!; + public int Count => _count; + + public T this[int index] => _listeners[index]!; public BasicEventListeners() : this(4) { } public BasicEventListeners(int initialSize) { - Capacity = Math.Max(1, initialSize); - Hashes = new int[Capacity]; - Listeners = new T[Capacity]; + _capacity = Math.Max(1, initialSize); + _hashes = new int[_capacity]; + _listeners = new T[_capacity]; } public void Add(T listener) @@ -61,13 +64,13 @@ public void Add(T listener) var hashCode = listener.GetHashCode(); - if (CountValue <= SmallListenerThreshold) + if (_count <= SmallListenerThreshold) { if (FindLinear(listener) >= 0) return; AddRaw(hashCode, listener); - if (CountValue > SmallListenerThreshold) + if (_count > SmallListenerThreshold) { RebuildIndex(); } @@ -75,7 +78,7 @@ public void Add(T listener) return; } - var indices = Indices!; + var indices = _indices!; if (!indices.TryGetValue(hashCode, out var index)) { @@ -85,20 +88,20 @@ public void Add(T listener) if (index != CollisionBucket) { - if (DelegateEquals(Listeners[index]!, listener)) return; + if (DelegateEquals(_listeners[index]!, listener)) return; var newIndex = AddRaw(hashCode, listener); - Collisions ??= new Dictionary>(); - Collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); + _collisions ??= new Dictionary>(); + _collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); indices[hashCode] = CollisionBucket; return; } - var bucket = Collisions![hashCode]; + var bucket = _collisions![hashCode]; for (var i = 0; i < bucket.Count; i++) { - if (DelegateEquals(Listeners[bucket[i]]!, listener)) return; + if (DelegateEquals(_listeners[bucket[i]]!, listener)) return; } bucket.Add(AddRaw(hashCode, listener)); @@ -106,11 +109,11 @@ public void Add(T listener) public void Remove(T listener) { - if (listener == null || CountValue <= 0) return; + if (listener == null || _count <= 0) return; var hashCode = listener.GetHashCode(); - if (CountValue <= SmallListenerThreshold) + if (_count <= SmallListenerThreshold) { var index = FindLinear(listener); if (index >= 0) @@ -122,7 +125,7 @@ public void Remove(T listener) return; } - var indices = Indices!; + var indices = _indices!; if (!indices.TryGetValue(hashCode, out var mappedIndex)) return; @@ -130,19 +133,19 @@ public void Remove(T listener) if (mappedIndex != CollisionBucket) { - if (!DelegateEquals(Listeners[mappedIndex]!, listener)) return; + if (!DelegateEquals(_listeners[mappedIndex]!, listener)) return; removeIndex = mappedIndex; indices.Remove(hashCode); } else { - var bucket = Collisions![hashCode]; + var bucket = _collisions![hashCode]; for (var i = 0; i < bucket.Count; i++) { var candidate = bucket[i]; - if (!DelegateEquals(Listeners[candidate]!, listener)) continue; + if (!DelegateEquals(_listeners[candidate]!, listener)) continue; removeIndex = candidate; RemoveBucketSlot(bucket, i); @@ -150,13 +153,13 @@ public void Remove(T listener) if (bucket.Count == 1) { indices[hashCode] = bucket[0]; - Collisions.Remove(hashCode); + _collisions.Remove(hashCode); ReturnCollisionsListToPool(bucket); } else if (bucket.Count == 0) { indices.Remove(hashCode); - Collisions.Remove(hashCode); + _collisions.Remove(hashCode); ReturnCollisionsListToPool(bucket); } @@ -166,12 +169,12 @@ public void Remove(T listener) if (removeIndex < 0) return; } - var movedFrom = CountValue - 1; - var movedHashCode = Hashes[movedFrom]; + var movedFrom = _count - 1; + var movedHashCode = _hashes[movedFrom]; RemoveAtSwapBackRaw(removeIndex); - if (CountValue <= SmallListenerThreshold) + if (_count <= SmallListenerThreshold) { ClearIndex(); } @@ -181,97 +184,103 @@ public void Remove(T listener) } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private int FindLinear(T listener) { - for (var i = 0; i < CountValue; i++) + for (var i = 0; i < _count; i++) { - if (DelegateEquals(Listeners[i]!, listener)) return i; + if (DelegateEquals(_listeners[i]!, listener)) return i; } return -1; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private int AddRaw(int hashCode, T listener) { EnsureCapacity(); - var index = CountValue; - Hashes[index] = hashCode; - Listeners[index] = listener; - CountValue++; + var index = _count; + _hashes[index] = hashCode; + _listeners[index] = listener; + _count++; return index; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RemoveAtSwapBackRaw(int index) { - var lastIndex = CountValue - 1; + var lastIndex = _count - 1; if (index != lastIndex) { - Hashes[index] = Hashes[lastIndex]; - Listeners[index] = Listeners[lastIndex]; + _hashes[index] = _hashes[lastIndex]; + _listeners[index] = _listeners[lastIndex]; } - Hashes[lastIndex] = 0; - Listeners[lastIndex] = null; - CountValue = lastIndex; + _hashes[lastIndex] = 0; + _listeners[lastIndex] = null; + _count = lastIndex; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void EnsureCapacity() { - if (CountValue < Capacity) return; + if (_count < _capacity) return; - Capacity *= 2; - Array.Resize(ref Hashes, Capacity); - Array.Resize(ref Listeners, Capacity); + _capacity *= 2; + Array.Resize(ref _hashes, _capacity); + Array.Resize(ref _listeners, _capacity); } private void RebuildIndex() { - if (Indices == null) + if (_indices == null) { - Indices = new Dictionary(Capacity); + _indices = new Dictionary(_capacity); } else { ClearIndex(); } - for (var i = 0; i < CountValue; i++) + var indices = _indices; + for (var i = 0; i < _count; i++) { - var hashCode = Hashes[i]; + var hashCode = _hashes[i]; - if (!Indices.TryGetValue(hashCode, out var existing)) + if (!indices.TryGetValue(hashCode, out var existing)) { - Indices.Add(hashCode, i); + indices.Add(hashCode, i); continue; } - Collisions ??= new Dictionary>(); + _collisions ??= new Dictionary>(); if (existing != CollisionBucket) { - Collisions[hashCode] = GetCollisionsListFromPool(existing, i); - Indices[hashCode] = CollisionBucket; + _collisions[hashCode] = GetCollisionsListFromPool(existing, i); + indices[hashCode] = CollisionBucket; } else { - Collisions[hashCode].Add(i); + _collisions[hashCode].Add(i); } } } private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) { - var mappedIndex = Indices![hashCode]; + var indices = _indices!; + var mappedIndex = indices[hashCode]; if (mappedIndex != CollisionBucket) { - Indices[hashCode] = newIndex; + indices[hashCode] = newIndex; return; } - var bucket = Collisions![hashCode]; + var bucket = _collisions![hashCode]; for (var i = 0; i < bucket.Count; i++) { @@ -283,6 +292,7 @@ private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void RemoveBucketSlot(List bucket, int slot) { var lastSlot = bucket.Count - 1; @@ -297,23 +307,24 @@ private static void RemoveBucketSlot(List bucket, int slot) private void ClearIndex() { - Indices?.Clear(); + _indices?.Clear(); - if (Collisions == null) return; + if (_collisions == null) return; - foreach (var collisions in Collisions.Values) + foreach (var collisions in _collisions.Values) { ReturnCollisionsListToPool(collisions); } - Collisions.Clear(); + _collisions.Clear(); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private List GetCollisionsListFromPool(int a, int b) { - if (CollisionsPool == null || CollisionsPool.Count <= 0) return new List(2) { a, b }; + if (_collisionsPool == null || _collisionsPool.Count <= 0) return new List(2) { a, b }; - var list = CollisionsPool.Pop(); + var list = _collisionsPool.Pop(); list.Add(a); list.Add(b); return list; @@ -322,10 +333,11 @@ private List GetCollisionsListFromPool(int a, int b) private void ReturnCollisionsListToPool(List list) { list.Clear(); - CollisionsPool ??= new Stack>(); - CollisionsPool.Push(list); + _collisionsPool ??= new Stack>(); + _collisionsPool.Push(list); } - private static bool DelegateEquals(T a, T b) => EqualityComparer.Default.Equals(a, b); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool DelegateEquals(T a, T b) => Comparer.Equals(a, b); } } diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs index e073b2407fb..79a3ebc5bd9 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -1,6 +1,7 @@ #if SAPPY using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Sappy; using SpacetimeDB.EventHandling; @@ -8,32 +9,25 @@ namespace SpacetimeDB.SappyIntegration { public class SappyEventListeners : IEventListeners where T : Delegate { - private SapDelegate Targets { get; } = new(); - private TargetCache Cache { get; } = new(4); + private readonly TargetCache _cache = new(4); - public void Add(SapTarget listener) => Targets.Add(listener); - public void Remove(SapTarget listener) => Targets.Remove(listener); + public void Add(SapTarget listener) => _cache.Add(listener); + public void Remove(SapTarget listener) => _cache.Remove(listener); - public int Count => Targets.Count; + public int Count => _cache.Count; - public T this[int index] => Targets[index]; + public T this[int index] => _cache[index]; public void Add(T listener) { if (listener == null) return; - if (Cache.Add(listener, out var target)) - { - Add(target); - } + _cache.Add(listener); } public void Remove(T listener) { if (listener == null) return; - if (Cache.Remove(listener, out var target)) - { - Remove(target); - } + _cache.Remove(listener); } private sealed class TargetCache @@ -41,38 +35,93 @@ private sealed class TargetCache private const int SmallListenerThreshold = 8; private const int CollisionBucket = -1; - private int[] Hashes; - private T?[] Callbacks; - private SapTarget?[] Targets; - private int Capacity { get; set; } - private int Count { get; set; } - private Dictionary? Indices { get; set; } - private Dictionary>? Collisions { get; set; } - private Stack>? CollisionsPool { get; set; } + private static readonly EqualityComparer Comparer = EqualityComparer.Default; + + private int[] _hashes; + private T?[] _callbacks; + private SapTarget?[] _targets; + private int _capacity; + private int _count; + private Dictionary? _indices; + private Dictionary>? _collisions; + private Stack>? _collisionsPool; + + public int Count => _count; + + public T this[int index] => _callbacks[index]!; public TargetCache(int capacity) { - Capacity = Math.Max(1, capacity); - Hashes = new int[Capacity]; - Callbacks = new T[Capacity]; - Targets = new SapTarget[Capacity]; + _capacity = Math.Max(1, capacity); + _hashes = new int[_capacity]; + _callbacks = new T[_capacity]; + _targets = new SapTarget[_capacity]; } - public bool Add(T callback, out SapTarget target) + public bool Add(T callback) { - target = null!; if (callback == null) return false; var hashCode = callback.GetHashCode(); - if (Count <= SmallListenerThreshold) + if (_count <= SmallListenerThreshold) + { + if (FindLinear(callback) >= 0) return false; + + AddRaw(hashCode, callback, new SapTarget(callback)); + + if (_count > SmallListenerThreshold) + { + RebuildIndex(); + } + + return true; + } + + var indices = _indices!; + + if (!indices.TryGetValue(hashCode, out var index)) + { + indices.Add(hashCode, AddRaw(hashCode, callback, new SapTarget(callback))); + return true; + } + + if (index != CollisionBucket) + { + if (DelegateEquals(_callbacks[index]!, callback)) return false; + + var newIndex = AddRaw(hashCode, callback, new SapTarget(callback)); + _collisions ??= new Dictionary>(); + _collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); + indices[hashCode] = CollisionBucket; + return true; + } + + var bucket = _collisions![hashCode]; + + for (var i = 0; i < bucket.Count; i++) + { + if (DelegateEquals(_callbacks[bucket[i]]!, callback)) return false; + } + + bucket.Add(AddRaw(hashCode, callback, new SapTarget(callback))); + return true; + } + + public bool Add(SapTarget target) + { + if (target == null || target.Callback == null) return false; + + var hashCode = target.HashCode; + var callback = target.Callback; + + if (_count <= SmallListenerThreshold) { if (FindLinear(callback) >= 0) return false; - target = new SapTarget(callback); AddRaw(hashCode, callback, target); - if (Count > SmallListenerThreshold) + if (_count > SmallListenerThreshold) { RebuildIndex(); } @@ -80,52 +129,57 @@ public bool Add(T callback, out SapTarget target) return true; } - var indices = Indices!; + var indices = _indices!; if (!indices.TryGetValue(hashCode, out var index)) { - target = new SapTarget(callback); indices.Add(hashCode, AddRaw(hashCode, callback, target)); return true; } if (index != CollisionBucket) { - if (DelegateEquals(Callbacks[index]!, callback)) return false; + if (DelegateEquals(_callbacks[index]!, callback)) return false; - target = new SapTarget(callback); var newIndex = AddRaw(hashCode, callback, target); - Collisions ??= new Dictionary>(); - Collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); + _collisions ??= new Dictionary>(); + _collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); indices[hashCode] = CollisionBucket; return true; } - var bucket = Collisions![hashCode]; + var bucket = _collisions![hashCode]; for (var i = 0; i < bucket.Count; i++) { - if (DelegateEquals(Callbacks[bucket[i]]!, callback)) return false; + if (DelegateEquals(_callbacks[bucket[i]]!, callback)) return false; } - target = new SapTarget(callback); bucket.Add(AddRaw(hashCode, callback, target)); return true; } - public bool Remove(T callback, out SapTarget target) + public bool Remove(T callback) { - target = null!; - if (callback == null || Count <= 0) return false; + if (callback == null || _count <= 0) return false; var hashCode = callback.GetHashCode(); + return Remove(hashCode, callback); + } - if (Count <= SmallListenerThreshold) + public bool Remove(SapTarget target) + { + if (target == null || target.Callback == null || _count <= 0) return false; + return Remove(target.HashCode, target.Callback); + } + + private bool Remove(int hashCode, T callback) + { + if (_count <= SmallListenerThreshold) { var index = FindLinear(callback); if (index >= 0) { - target = Targets[index]!; RemoveAtSwapBackRaw(index); ClearIndex(); return true; @@ -134,7 +188,7 @@ public bool Remove(T callback, out SapTarget target) return false; } - var indices = Indices!; + var indices = _indices!; if (!indices.TryGetValue(hashCode, out var mappedIndex)) return false; @@ -142,19 +196,19 @@ public bool Remove(T callback, out SapTarget target) if (mappedIndex != CollisionBucket) { - if (!DelegateEquals(Callbacks[mappedIndex]!, callback)) return false; + if (!DelegateEquals(_callbacks[mappedIndex]!, callback)) return false; removeIndex = mappedIndex; indices.Remove(hashCode); } else { - var bucket = Collisions![hashCode]; + var bucket = _collisions![hashCode]; for (var i = 0; i < bucket.Count; i++) { var candidate = bucket[i]; - if (!DelegateEquals(Callbacks[candidate]!, callback)) continue; + if (!DelegateEquals(_callbacks[candidate]!, callback)) continue; removeIndex = candidate; RemoveBucketSlot(bucket, i); @@ -162,13 +216,13 @@ public bool Remove(T callback, out SapTarget target) if (bucket.Count == 1) { indices[hashCode] = bucket[0]; - Collisions.Remove(hashCode); + _collisions.Remove(hashCode); ReturnCollisionsListToPool(bucket); } else if (bucket.Count == 0) { indices.Remove(hashCode); - Collisions.Remove(hashCode); + _collisions.Remove(hashCode); ReturnCollisionsListToPool(bucket); } @@ -178,13 +232,12 @@ public bool Remove(T callback, out SapTarget target) if (removeIndex < 0) return false; } - var movedFrom = Count - 1; - var movedHashCode = Hashes[movedFrom]; - target = Targets[removeIndex]!; + var movedFrom = _count - 1; + var movedHashCode = _hashes[movedFrom]; RemoveAtSwapBackRaw(removeIndex); - if (Count <= SmallListenerThreshold) + if (_count <= SmallListenerThreshold) { ClearIndex(); } @@ -196,101 +249,107 @@ public bool Remove(T callback, out SapTarget target) return true; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private int FindLinear(T callback) { - for (var i = 0; i < Count; i++) + for (var i = 0; i < _count; i++) { - if (DelegateEquals(Callbacks[i]!, callback)) return i; + if (DelegateEquals(_callbacks[i]!, callback)) return i; } return -1; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private int AddRaw(int hashCode, T callback, SapTarget target) { EnsureCapacity(); - var index = Count; - Hashes[index] = hashCode; - Callbacks[index] = callback; - Targets[index] = target; - Count++; + var index = _count; + _hashes[index] = hashCode; + _callbacks[index] = callback; + _targets[index] = target; + _count++; return index; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RemoveAtSwapBackRaw(int index) { - var lastIndex = Count - 1; + var lastIndex = _count - 1; if (index != lastIndex) { - Hashes[index] = Hashes[lastIndex]; - Callbacks[index] = Callbacks[lastIndex]; - Targets[index] = Targets[lastIndex]; + _hashes[index] = _hashes[lastIndex]; + _callbacks[index] = _callbacks[lastIndex]; + _targets[index] = _targets[lastIndex]; } - Hashes[lastIndex] = 0; - Callbacks[lastIndex] = null; - Targets[lastIndex] = null; - Count = lastIndex; + _hashes[lastIndex] = 0; + _callbacks[lastIndex] = null; + _targets[lastIndex] = null; + _count = lastIndex; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void EnsureCapacity() { - if (Count < Capacity) return; + if (_count < _capacity) return; - Capacity *= 2; - Array.Resize(ref Hashes, Capacity); - Array.Resize(ref Callbacks, Capacity); - Array.Resize(ref Targets, Capacity); + _capacity *= 2; + Array.Resize(ref _hashes, _capacity); + Array.Resize(ref _callbacks, _capacity); + Array.Resize(ref _targets, _capacity); } private void RebuildIndex() { - if (Indices == null) + if (_indices == null) { - Indices = new Dictionary(Capacity); + _indices = new Dictionary(_capacity); } else { ClearIndex(); } - for (var i = 0; i < Count; i++) + var indices = _indices; + for (var i = 0; i < _count; i++) { - var hashCode = Hashes[i]; + var hashCode = _hashes[i]; - if (!Indices.TryGetValue(hashCode, out var existing)) + if (!indices.TryGetValue(hashCode, out var existing)) { - Indices.Add(hashCode, i); + indices.Add(hashCode, i); continue; } - Collisions ??= new Dictionary>(); + _collisions ??= new Dictionary>(); if (existing != CollisionBucket) { - Collisions[hashCode] = GetCollisionsListFromPool(existing, i); - Indices[hashCode] = CollisionBucket; + _collisions[hashCode] = GetCollisionsListFromPool(existing, i); + indices[hashCode] = CollisionBucket; } else { - Collisions[hashCode].Add(i); + _collisions[hashCode].Add(i); } } } private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) { - var mappedIndex = Indices![hashCode]; + var indices = _indices!; + var mappedIndex = indices[hashCode]; if (mappedIndex != CollisionBucket) { - Indices[hashCode] = newIndex; + indices[hashCode] = newIndex; return; } - var bucket = Collisions![hashCode]; + var bucket = _collisions![hashCode]; for (var i = 0; i < bucket.Count; i++) { @@ -302,6 +361,7 @@ private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void RemoveBucketSlot(List bucket, int slot) { var lastSlot = bucket.Count - 1; @@ -316,23 +376,24 @@ private static void RemoveBucketSlot(List bucket, int slot) private void ClearIndex() { - Indices?.Clear(); + _indices?.Clear(); - if (Collisions == null) return; + if (_collisions == null) return; - foreach (var collisions in Collisions.Values) + foreach (var collisions in _collisions.Values) { ReturnCollisionsListToPool(collisions); } - Collisions.Clear(); + _collisions.Clear(); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private List GetCollisionsListFromPool(int a, int b) { - if (CollisionsPool == null || CollisionsPool.Count <= 0) return new List(2) { a, b }; + if (_collisionsPool == null || _collisionsPool.Count <= 0) return new List(2) { a, b }; - var list = CollisionsPool.Pop(); + var list = _collisionsPool.Pop(); list.Add(a); list.Add(b); return list; @@ -341,11 +402,12 @@ private List GetCollisionsListFromPool(int a, int b) private void ReturnCollisionsListToPool(List list) { list.Clear(); - CollisionsPool ??= new Stack>(); - CollisionsPool.Push(list); + _collisionsPool ??= new Stack>(); + _collisionsPool.Push(list); } - private static bool DelegateEquals(T a, T b) => EqualityComparer.Default.Equals(a, b); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool DelegateEquals(T a, T b) => Comparer.Equals(a, b); } } } diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 38b03f08367..3a558b42413 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -575,9 +575,10 @@ protected class CustomRowEventHandler public void Invoke(EventContext ctx, Row row) { - for (var i = Listeners.Count - 1; i >= 0; i--) + var listeners = Listeners; + for (var i = listeners.Count - 1; i >= 0; i--) { - Listeners[i].Invoke(ctx, row); + listeners[i].Invoke(ctx, row); } } } @@ -587,9 +588,10 @@ protected class CustomUpdateEventHandler public void Invoke(EventContext ctx, Row oldRow, Row newRow) { - for (var i = Listeners.Count - 1; i >= 0; i--) + var listeners = Listeners; + for (var i = listeners.Count - 1; i >= 0; i--) { - Listeners[i].Invoke(ctx, oldRow, newRow); + listeners[i].Invoke(ctx, oldRow, newRow); } } } From 506a818b671485a0956523f8c106aa30e3028ebd Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 17 Aug 2026 13:11:03 -0500 Subject: [PATCH 17/22] Use native events by default --- .../src/EventHandling/EventListeners.cs | 24 +++- .../SappyEventListenersFactory.cs | 7 -- sdks/csharp/src/Table.cs | 110 ++++++++++++++++-- 3 files changed, 118 insertions(+), 23 deletions(-) diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index 58fbc21e9ce..dac2a74302c 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -20,14 +20,30 @@ public interface IEventListenersFactory public static class EventListenersProvider { + private enum Backend + { + Native, + Custom, + } + + private static Backend SelectedBackend { get; set; } private static IEventListenersFactory? CustomFactory { get; set; } - public static IEventListeners Create() where T : Delegate => CustomFactory?.Create() ?? new BasicEventListeners(); - - public static void SetFactory(IEventListenersFactory factory) + internal static bool UseNativeDispatch => SelectedBackend == Backend.Native; + + public static void UseNativeEvents() { - CustomFactory = factory; + SelectedBackend = Backend.Native; + CustomFactory = null; } + + public static void UseCustomListeners(IEventListenersFactory? factory = null) + { + SelectedBackend = Backend.Custom; + CustomFactory = null; + } + + internal static IEventListeners Create() where T : Delegate => CustomFactory?.Create() ?? new BasicEventListeners(); } public class BasicEventListeners : IEventListeners where T : Delegate diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs b/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs index b4497116b98..4c5863d9307 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListenersFactory.cs @@ -7,13 +7,6 @@ namespace SpacetimeDB.SappyIntegration { public class SappyEventListenersFactory : IEventListenersFactory { - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] - private static void AutoRegister() - { - // Hand this implementation back to the main assembly - EventListenersProvider.SetFactory(new SappyEventListenersFactory()); - } - public IEventListeners Create() where T : Delegate => new SappyEventListeners(); } } diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 3a558b42413..52604964773 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -402,8 +402,8 @@ void IRemoteTableHandle.Parse(TableUpdate update, ParsedDatabaseUpdate dbOps) private CustomRowEventHandler OnInsertHandler { get; } = new(); public event RowEventHandler OnInsert { - add => OnInsertHandler.Listeners.Add(value); - remove => OnInsertHandler.Listeners.Remove(value); + add => OnInsertHandler.Add(value); + remove => OnInsertHandler.Remove(value); } #if SAPPY public IEventListeners OnInsertListeners => OnInsertHandler.Listeners; @@ -571,11 +571,54 @@ void IRemoteTableHandle.PostApply(IEventContext context) protected class CustomRowEventHandler { - public IEventListeners Listeners { get; } = EventListenersProvider.Create(); + private readonly bool _useNativeDispatch = EventListenersProvider.UseNativeDispatch; + private RowEventHandler? _nativeListeners; + private readonly IEventListeners? _indexedListeners; + + public IEventListeners Listeners => _indexedListeners ?? throw new InvalidOperationException( + "This event is using native C# event dispatch and does not expose indexed listeners. " + + "Use SpacetimeDB.EventHandling.EventListenersProvider.UseBasicEventListeners() or a custom listener factory before creating table handles." + ); + + public CustomRowEventHandler() + { + if (!_useNativeDispatch) + { + _indexedListeners = EventListenersProvider.Create(); + } + } + + public void Add(RowEventHandler listener) + { + if (_useNativeDispatch) + { + _nativeListeners += listener; + return; + } + + _indexedListeners!.Add(listener); + } + + public void Remove(RowEventHandler listener) + { + if (_useNativeDispatch) + { + _nativeListeners -= listener; + return; + } + + _indexedListeners!.Remove(listener); + } public void Invoke(EventContext ctx, Row row) { - var listeners = Listeners; + if (_useNativeDispatch) + { + _nativeListeners?.Invoke(ctx, row); + return; + } + + var listeners = _indexedListeners!; for (var i = listeners.Count - 1; i >= 0; i--) { listeners[i].Invoke(ctx, row); @@ -584,11 +627,54 @@ public void Invoke(EventContext ctx, Row row) } protected class CustomUpdateEventHandler { - public IEventListeners Listeners { get; } = EventListenersProvider.Create(); + private readonly bool _useNativeDispatch = EventListenersProvider.UseNativeDispatch; + private UpdateEventHandler? _nativeListeners; + private readonly IEventListeners? _indexedListeners; + + public IEventListeners Listeners => _indexedListeners ?? throw new InvalidOperationException( + "This event is using native C# event dispatch and does not expose indexed listeners. " + + "Use SpacetimeDB.EventHandling.EventListenersProvider.UseBasicEventListeners() or a custom listener factory before creating table handles." + ); + + public CustomUpdateEventHandler() + { + if (!_useNativeDispatch) + { + _indexedListeners = EventListenersProvider.Create(); + } + } + + public void Add(UpdateEventHandler listener) + { + if (_useNativeDispatch) + { + _nativeListeners += listener; + return; + } + + _indexedListeners!.Add(listener); + } + + public void Remove(UpdateEventHandler listener) + { + if (_useNativeDispatch) + { + _nativeListeners -= listener; + return; + } + + _indexedListeners!.Remove(listener); + } public void Invoke(EventContext ctx, Row oldRow, Row newRow) { - var listeners = Listeners; + if (_useNativeDispatch) + { + _nativeListeners?.Invoke(ctx, oldRow, newRow); + return; + } + + var listeners = _indexedListeners!; for (var i = listeners.Count - 1; i >= 0; i--) { listeners[i].Invoke(ctx, oldRow, newRow); @@ -609,8 +695,8 @@ protected RemoteTableHandle(IDbConnection conn) : base(conn) { } private CustomRowEventHandler OnDeleteHandler { get; } = new(); public event RowEventHandler OnDelete { - add => OnDeleteHandler.Listeners.Add(value); - remove => OnDeleteHandler.Listeners.Remove(value); + add => OnDeleteHandler.Add(value); + remove => OnDeleteHandler.Remove(value); } #if SAPPY public IEventListeners OnDeleteListeners => OnDeleteHandler.Listeners; @@ -619,8 +705,8 @@ public event RowEventHandler OnDelete private CustomRowEventHandler OnBeforeDeleteHandler { get; } = new(); public event RowEventHandler OnBeforeDelete { - add => OnBeforeDeleteHandler.Listeners.Add(value); - remove => OnBeforeDeleteHandler.Listeners.Remove(value); + add => OnBeforeDeleteHandler.Add(value); + remove => OnBeforeDeleteHandler.Remove(value); } #if SAPPY public IEventListeners OnBeforeDeleteListeners => OnBeforeDeleteHandler.Listeners; @@ -629,8 +715,8 @@ public event RowEventHandler OnBeforeDelete private CustomUpdateEventHandler OnUpdateHandler { get; } = new(); public event UpdateEventHandler OnUpdate { - add => OnUpdateHandler.Listeners.Add(value); - remove => OnUpdateHandler.Listeners.Remove(value); + add => OnUpdateHandler.Add(value); + remove => OnUpdateHandler.Remove(value); } #if SAPPY public IEventListeners OnUpdateListeners => OnUpdateHandler.Listeners; From e08dfba5b14d6290782e494d8917cec2f9be10c7 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 17 Aug 2026 14:12:27 -0500 Subject: [PATCH 18/22] Clean --- sdks/csharp/src/EventHandling/Backend.cs | 24 +++++++++ .../src/EventHandling/EventListeners.cs | 50 ++----------------- .../src/EventHandling/IEventListeners.cs | 18 +++++++ sdks/csharp/src/Table.cs | 8 +-- 4 files changed, 50 insertions(+), 50 deletions(-) create mode 100644 sdks/csharp/src/EventHandling/Backend.cs create mode 100644 sdks/csharp/src/EventHandling/IEventListeners.cs diff --git a/sdks/csharp/src/EventHandling/Backend.cs b/sdks/csharp/src/EventHandling/Backend.cs new file mode 100644 index 00000000000..7a4f119224c --- /dev/null +++ b/sdks/csharp/src/EventHandling/Backend.cs @@ -0,0 +1,24 @@ +using System; + +namespace SpacetimeDB.EventHandling +{ + public static class Backend + { + internal static bool UseNativeDispatch { get; private set; } = true; + private static IEventListenersFactory? CustomFactory { get; set; } + + public static void UseNativeEvents() + { + UseNativeDispatch = true; + CustomFactory = null; + } + + public static void UseCustomListeners(IEventListenersFactory? factory = null) + { + UseNativeDispatch = false; + CustomFactory = null; + } + + internal static IEventListeners Create() where T : Delegate => CustomFactory?.Create() ?? new EventListeners(); + } +} \ No newline at end of file diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index dac2a74302c..bb558b3ab84 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -4,49 +4,7 @@ namespace SpacetimeDB.EventHandling { - public interface IEventListeners where T : Delegate - { - int Count { get; } - T this[int index] { get; } - - void Add(T listener); - void Remove(T listener); - } - - public interface IEventListenersFactory - { - IEventListeners Create() where T : Delegate; - } - - public static class EventListenersProvider - { - private enum Backend - { - Native, - Custom, - } - - private static Backend SelectedBackend { get; set; } - private static IEventListenersFactory? CustomFactory { get; set; } - - internal static bool UseNativeDispatch => SelectedBackend == Backend.Native; - - public static void UseNativeEvents() - { - SelectedBackend = Backend.Native; - CustomFactory = null; - } - - public static void UseCustomListeners(IEventListenersFactory? factory = null) - { - SelectedBackend = Backend.Custom; - CustomFactory = null; - } - - internal static IEventListeners Create() where T : Delegate => CustomFactory?.Create() ?? new BasicEventListeners(); - } - - public class BasicEventListeners : IEventListeners where T : Delegate + internal class EventListeners : IEventListeners where T : Delegate { private const int SmallListenerThreshold = 8; private const int CollisionBucket = -1; @@ -65,9 +23,9 @@ public class BasicEventListeners : IEventListeners where T : Delegate public T this[int index] => _listeners[index]!; - public BasicEventListeners() : this(4) { } + public EventListeners() : this(4) { } - public BasicEventListeners(int initialSize) + public EventListeners(int initialSize) { _capacity = Math.Max(1, initialSize); _hashes = new int[_capacity]; @@ -356,4 +314,4 @@ private void ReturnCollisionsListToPool(List list) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool DelegateEquals(T a, T b) => Comparer.Equals(a, b); } -} +} \ No newline at end of file diff --git a/sdks/csharp/src/EventHandling/IEventListeners.cs b/sdks/csharp/src/EventHandling/IEventListeners.cs new file mode 100644 index 00000000000..fad7c71e9e9 --- /dev/null +++ b/sdks/csharp/src/EventHandling/IEventListeners.cs @@ -0,0 +1,18 @@ +using System; + +namespace SpacetimeDB.EventHandling +{ + public interface IEventListeners where T : Delegate + { + int Count { get; } + T this[int index] { get; } + + void Add(T listener); + void Remove(T listener); + } + + public interface IEventListenersFactory + { + IEventListeners Create() where T : Delegate; + } +} diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 52604964773..c37faccebe8 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -571,7 +571,7 @@ void IRemoteTableHandle.PostApply(IEventContext context) protected class CustomRowEventHandler { - private readonly bool _useNativeDispatch = EventListenersProvider.UseNativeDispatch; + private readonly bool _useNativeDispatch = Backend.UseNativeDispatch; private RowEventHandler? _nativeListeners; private readonly IEventListeners? _indexedListeners; @@ -584,7 +584,7 @@ public CustomRowEventHandler() { if (!_useNativeDispatch) { - _indexedListeners = EventListenersProvider.Create(); + _indexedListeners = Backend.Create(); } } @@ -627,7 +627,7 @@ public void Invoke(EventContext ctx, Row row) } protected class CustomUpdateEventHandler { - private readonly bool _useNativeDispatch = EventListenersProvider.UseNativeDispatch; + private readonly bool _useNativeDispatch = Backend.UseNativeDispatch; private UpdateEventHandler? _nativeListeners; private readonly IEventListeners? _indexedListeners; @@ -640,7 +640,7 @@ public CustomUpdateEventHandler() { if (!_useNativeDispatch) { - _indexedListeners = EventListenersProvider.Create(); + _indexedListeners = Backend.Create(); } } From 0c65e56d105e51a0979f626203649ec8d46058ae Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 17 Aug 2026 14:19:37 -0500 Subject: [PATCH 19/22] Fix bug where no custom factory could be used --- sdks/csharp/src/EventHandling/Backend.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/csharp/src/EventHandling/Backend.cs b/sdks/csharp/src/EventHandling/Backend.cs index 7a4f119224c..ec540e83c91 100644 --- a/sdks/csharp/src/EventHandling/Backend.cs +++ b/sdks/csharp/src/EventHandling/Backend.cs @@ -16,7 +16,7 @@ public static void UseNativeEvents() public static void UseCustomListeners(IEventListenersFactory? factory = null) { UseNativeDispatch = false; - CustomFactory = null; + CustomFactory = factory; } internal static IEventListeners Create() where T : Delegate => CustomFactory?.Create() ?? new EventListeners(); From 2ce9d4930f3096cb12862858adbf1a5325fb09d8 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 18 Aug 2026 08:42:52 -0500 Subject: [PATCH 20/22] Benchmark by Codex --- .../event-handling-benchmarks/README.md | 31 ++ .../client/Program.cs | 353 ++++++++++++++++++ .../client/client.csproj | 23 ++ 3 files changed, 407 insertions(+) create mode 100644 sdks/csharp/examples~/event-handling-benchmarks/README.md create mode 100644 sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs create mode 100644 sdks/csharp/examples~/event-handling-benchmarks/client/client.csproj diff --git a/sdks/csharp/examples~/event-handling-benchmarks/README.md b/sdks/csharp/examples~/event-handling-benchmarks/README.md new file mode 100644 index 00000000000..62c72beffdb --- /dev/null +++ b/sdks/csharp/examples~/event-handling-benchmarks/README.md @@ -0,0 +1,31 @@ +# C# event handling benchmarks + +This benchmark client measures table event subscription, update dispatch, unsubscription, and resubscription against a real SpacetimeDB module. + +It reuses the C# regression-test module and generated bindings. Publish that module first, then run this client against it. + +```sh +spacetime start +spacetime publish event-handling-bench sdks/csharp/examples~/regression-tests/server +dotnet run -c Release --project sdks/csharp/examples~/event-handling-benchmarks/client/client.csproj +``` + +Environment variables: + +- `SPACETIMEDB_SERVER_URL`: server URL, default `http://localhost:3000`. +- `SPACETIMEDB_DATABASE`: database name, default `event-handling-bench`. +- `SPACETIMEDB_EVENT_BACKEND`: `all`, `native`, `custom`, or `sappy`. + +Backends: + +- `native`: native C# multicast delegate dispatch. +- `custom`: SDK custom indexed listener dispatch. +- `sappy`: Sappy-backed custom listener dispatch. This path requires compiling with `SAPPY=1` in a project that references the Sappy package, such as a Unity project with the SDK and Sappy integration assemblies present. + +Scenarios: + +- Few subscriptions, many updates. +- Many subscriptions, some updates. +- Many subscriptions, many updates. +- Many subscriptions, some updates, many unsubscriptions. +- Many subscriptions, some updates, many unsubscriptions, many resubscriptions, some updates. diff --git a/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs b/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs new file mode 100644 index 00000000000..5bfb3615ba6 --- /dev/null +++ b/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs @@ -0,0 +1,353 @@ +using System.Diagnostics; +using RegressionTests.Shared; +using SpacetimeDB; +using SpacetimeDB.EventHandling; +using SpacetimeDB.Types; +using ExampleDataInsertHandler = SpacetimeDB.RemoteTableHandleBase.RowEventHandler; +#if SAPPY +using SpacetimeDB.SappyIntegration; +#endif + +const string DefaultHost = "http://localhost:3000"; +const string DefaultDatabase = "event-handling-bench"; +var host = Environment.GetEnvironmentVariable("SPACETIMEDB_SERVER_URL") ?? DefaultHost; +var database = Environment.GetEnvironmentVariable("SPACETIMEDB_DATABASE") ?? DefaultDatabase; +var backend = ParseBackend(Environment.GetEnvironmentVariable("SPACETIMEDB_EVENT_BACKEND") ?? "all"); + +var scenarios = new[] +{ + new Scenario("few-subscriptions-many-updates", Subscriptions: 10, FirstUpdates: 1_000), + new Scenario("many-subscriptions-some-updates", Subscriptions: 1_000, FirstUpdates: 10), + new Scenario("many-subscriptions-many-updates", Subscriptions: 1_000, FirstUpdates: 1_000), + new Scenario("many-subscriptions-some-updates-many-unsubscriptions", Subscriptions: 1_000, FirstUpdates: 10, Unsubscriptions: 1_000), + new Scenario("many-subscriptions-some-updates-many-unsubscriptions-resubscriptions-some-updates", Subscriptions: 1_000, FirstUpdates: 10, Unsubscriptions: 1_000, Resubscriptions: 1_000, SecondUpdates: 10), +}; + +RegressionTestHarness.RegisterUnhandledExceptionExitHandler(); + +Console.WriteLine($"Host: {host}"); +Console.WriteLine($"Database: {database}"); +Console.WriteLine("| Backend | Scenario | Subscriptions | First updates | Unsubscriptions | Resubscriptions | Second updates | Subscribe ms | First updates ms | Unsubscribe ms | Resubscribe ms | Second updates ms | Listener calls |"); +Console.WriteLine("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); + +foreach (var backendKind in ExpandBackends(backend)) +{ + ConfigureBackend(backendKind); + + foreach (var scenario in scenarios) + { + using var runner = new BenchmarkRunner(host, database, backendKind, scenario); + var result = runner.Run(); + Console.WriteLine( + $"| {backendKind} | {scenario.Name} | {scenario.Subscriptions} | {scenario.FirstUpdates} | {scenario.Unsubscriptions} | {scenario.Resubscriptions} | {scenario.SecondUpdates} | " + + $"{result.Subscribe.TotalMilliseconds:F3} | {result.FirstUpdates.TotalMilliseconds:F3} | {result.Unsubscribe.TotalMilliseconds:F3} | {result.Resubscribe.TotalMilliseconds:F3} | {result.SecondUpdates.TotalMilliseconds:F3} | {result.ListenerCalls} |" + ); + } +} + +static BackendKind ParseBackend(string value) => + value.Trim().ToLowerInvariant() switch + { + "all" => BackendKind.All, + "native" => BackendKind.Native, + "custom" or "basic" => BackendKind.Custom, + "sappy" => BackendKind.Sappy, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Expected all, native, custom, basic, or sappy."), + }; + +static IEnumerable ExpandBackends(BackendKind backend) +{ + if (backend != BackendKind.All) + { + yield return backend; + yield break; + } + + yield return BackendKind.Native; + yield return BackendKind.Custom; +#if SAPPY + yield return BackendKind.Sappy; +#endif +} + +static void ConfigureBackend(BackendKind backend) +{ + switch (backend) + { + case BackendKind.Native: + Backend.UseNativeEvents(); + return; + case BackendKind.Custom: + Backend.UseCustomListeners(); + return; + case BackendKind.Sappy: +#if SAPPY + Backend.UseCustomListeners(new SappyEventListenersFactory()); + return; +#else + throw new InvalidOperationException("The Sappy benchmark requires compiling with SAPPY=1 inside a project that references Sappy."); +#endif + default: + throw new ArgumentOutOfRangeException(nameof(backend), backend, null); + } +} + +internal enum BackendKind +{ + All, + Native, + Custom, + Sappy, +} + +internal sealed record Scenario( + string Name, + int Subscriptions, + int FirstUpdates, + int Unsubscriptions = 0, + int Resubscriptions = 0, + int SecondUpdates = 0 +); + +internal readonly record struct BenchmarkResult( + TimeSpan Subscribe, + TimeSpan FirstUpdates, + TimeSpan Unsubscribe, + TimeSpan Resubscribe, + TimeSpan SecondUpdates, + long ListenerCalls +); + +internal static class BenchmarkSettings +{ + public const int TimeoutSeconds = 120; + public const int FrameSleepMilliseconds = 1; +} + +internal sealed class BenchmarkRunner : IDisposable +{ + private readonly string _host; + private readonly string _database; + private readonly BackendKind _backend; + private readonly Scenario _scenario; + private readonly ExampleDataInsertHandler[] _listeners; + private readonly Listener[] _listenerTargets; + private readonly object _lock = new(); + private DbConnection _conn = null!; + private SubscriptionHandle? _subscription; + private long _listenerCalls; + private long _targetListenerCalls; + private bool _connected; + private bool _subscriptionApplied; + private Exception? _error; + private uint _nextId; + + public BenchmarkRunner(string host, string database, BackendKind backend, Scenario scenario) + { + _host = host; + _database = database; + _backend = backend; + _scenario = scenario; + _listeners = new ExampleDataInsertHandler[scenario.Subscriptions]; + _listenerTargets = new Listener[scenario.Subscriptions]; + + var idBase = unchecked((uint)HashCode.Combine(Environment.ProcessId, DateTime.UtcNow.Ticks, backend, scenario.Name)); + _nextId = idBase == 0 ? 1 : idBase; + + for (var i = 0; i < _listeners.Length; i++) + { + _listenerTargets[i] = new Listener(this); + _listeners[i] = _listenerTargets[i].OnExampleDataInsert; + } + } + + public BenchmarkResult Run() + { + Connect(); + Subscribe(); + + var subscribe = Time(() => + { + foreach (var listener in _listeners) + { + _conn.Db.ExampleData.OnInsert += listener; + } + }); + + var firstUpdates = TimeUpdates(_scenario.FirstUpdates, _scenario.Subscriptions); + + var unsubscribe = Time(() => + { + for (var i = 0; i < _scenario.Unsubscriptions; i++) + { + _conn.Db.ExampleData.OnInsert -= _listeners[i]; + } + }); + + var resubscribe = Time(() => + { + for (var i = 0; i < _scenario.Resubscriptions; i++) + { + _conn.Db.ExampleData.OnInsert += _listeners[i]; + } + }); + + var activeAfterResubscribe = _scenario.Subscriptions - _scenario.Unsubscriptions + _scenario.Resubscriptions; + var secondUpdates = _scenario.SecondUpdates > 0 ? TimeUpdates(_scenario.SecondUpdates, activeAfterResubscribe) : TimeSpan.Zero; + + return new BenchmarkResult( + subscribe, + firstUpdates, + unsubscribe, + resubscribe, + secondUpdates, + Interlocked.Read(ref _listenerCalls) + ); + } + + private void Connect() + { + _conn = RegressionTestHarness.ConnectToDatabase( + _host, + _database, + (conn, _, _) => + { + lock (_lock) + { + _connected = true; + } + }, + error => RecordError(error), + error => + { + if (error != null) + { + RecordError(error); + } + } + ); + + TickUntil(() => _connected, "connect"); + } + + private void Subscribe() + { + _subscription = _conn.SubscriptionBuilder() + .OnApplied(_ => + { + lock (_lock) + { + _subscriptionApplied = true; + } + }) + .OnError((_, error) => RecordError(error)) + .AddQuery(q => q.From.ExampleData()) + .Subscribe(); + + TickUntil(() => _subscriptionApplied, "subscription applied"); + } + + private TimeSpan TimeUpdates(int updateCount, int activeSubscriptions) + { + if (updateCount <= 0) + { + return TimeSpan.Zero; + } + + var expectedCalls = activeSubscriptions * updateCount; + var before = Interlocked.Read(ref _listenerCalls); + Interlocked.Exchange(ref _targetListenerCalls, before + expectedCalls); + + return Time(() => + { + for (var i = 0; i < updateCount; i++) + { + _conn.Reducers.Add(NextId(), (uint)i); + } + + TickUntil(() => Interlocked.Read(ref _listenerCalls) >= Interlocked.Read(ref _targetListenerCalls), $"{updateCount} updates for {_scenario.Name}/{_backend}"); + }); + } + + private uint NextId() + { + var id = _nextId++; + if (id == 0) + { + id = _nextId++; + } + + return id; + } + + private void RecordInsert() + { + Interlocked.Increment(ref _listenerCalls); + } + + private TimeSpan Time(Action action) + { + var stopwatch = Stopwatch.StartNew(); + action(); + stopwatch.Stop(); + return stopwatch.Elapsed; + } + + private void TickUntil(Func complete, string phase) + { + var deadline = DateTime.UtcNow.AddSeconds(BenchmarkSettings.TimeoutSeconds); + while (!complete()) + { + ThrowIfError(); + _conn.FrameTick(); + Thread.Sleep(BenchmarkSettings.FrameSleepMilliseconds); + + if (DateTime.UtcNow > deadline) + { + throw new TimeoutException($"Timed out waiting for {phase}."); + } + } + + ThrowIfError(); + } + + private void RecordError(Exception error) + { + lock (_lock) + { + _error ??= error; + } + } + + private void ThrowIfError() + { + lock (_lock) + { + if (_error != null) + { + throw new InvalidOperationException("Benchmark connection failed.", _error); + } + } + } + + public void Dispose() + { + _subscription?.UnsubscribeThen(_ => { }); + _conn?.Disconnect(); + } + + private sealed class Listener + { + private readonly BenchmarkRunner _runner; + + public Listener(BenchmarkRunner runner) + { + _runner = runner; + } + + public void OnExampleDataInsert(EventContext ctx, ExampleData row) + { + _runner.RecordInsert(); + } + } +} diff --git a/sdks/csharp/examples~/event-handling-benchmarks/client/client.csproj b/sdks/csharp/examples~/event-handling-benchmarks/client/client.csproj new file mode 100644 index 00000000000..8fee3d03556 --- /dev/null +++ b/sdks/csharp/examples~/event-handling-benchmarks/client/client.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + enable + enable + + + + $(DefineConstants);SAPPY + + + + + + + + + + + + From 90542011eb17a1adfa2f11c8b624a541fdb4c535 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 19 Aug 2026 11:48:06 -0500 Subject: [PATCH 21/22] Add missing meta files --- sdks/csharp/src/EventHandling/Backend.cs.meta | 11 ++++++ .../src/EventHandling/IEventListeners.cs.meta | 11 ++++++ .../csharp/src/SappyIntegration/Extensions.cs | 34 ++++++++----------- 3 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 sdks/csharp/src/EventHandling/Backend.cs.meta create mode 100644 sdks/csharp/src/EventHandling/IEventListeners.cs.meta diff --git a/sdks/csharp/src/EventHandling/Backend.cs.meta b/sdks/csharp/src/EventHandling/Backend.cs.meta new file mode 100644 index 00000000000..e76cb3b3981 --- /dev/null +++ b/sdks/csharp/src/EventHandling/Backend.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6840d90a7134fdd92769b5e5d3f24b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/src/EventHandling/IEventListeners.cs.meta b/sdks/csharp/src/EventHandling/IEventListeners.cs.meta new file mode 100644 index 00000000000..f4adfa93fac --- /dev/null +++ b/sdks/csharp/src/EventHandling/IEventListeners.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b75e0a997a0c4637854774242df43635 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/src/SappyIntegration/Extensions.cs b/sdks/csharp/src/SappyIntegration/Extensions.cs index 9dbfd495012..1768f424c7b 100644 --- a/sdks/csharp/src/SappyIntegration/Extensions.cs +++ b/sdks/csharp/src/SappyIntegration/Extensions.cs @@ -7,33 +7,29 @@ namespace SpacetimeDB.SappyIntegration { public static class Extensions { - public static bool AddSapTarget(this IEventListeners listeners, SapTarget value) where T : Delegate + public static void AddSapTarget(this IEventListeners listeners, SapTarget value) where T : Delegate { - if (listeners is not SappyEventListeners sappyEventListeners) + if (listeners is SappyEventListeners sappyEventListeners) { - throw new InvalidOperationException( - "Cannot add a SapTarget because this listener collection is not backed by Sappy. " + - "Ensure the Sappy integration assembly registered before this table handle was created." - ); + sappyEventListeners.Add(value); + } + else + { + listeners.Add(value.Callback); } - - sappyEventListeners.Add(value); - return true; } - public static bool RemoveSapTarget(this IEventListeners listeners, SapTarget value) where T : Delegate + public static void RemoveSapTarget(this IEventListeners listeners, SapTarget value) where T : Delegate { - if (listeners is not SappyEventListeners sappyEventListeners) + if (listeners is SappyEventListeners sappyEventListeners) { - throw new InvalidOperationException( - "Cannot remove a SapTarget because this listener collection is not backed by Sappy. " + - "Ensure the Sappy integration assembly registered before this table handle was created." - ); + sappyEventListeners.Remove(value); + } + else + { + listeners.Add(value.Callback); } - - sappyEventListeners.Remove(value); - return true; } } } -#endif +#endif \ No newline at end of file From 06d14723d12326d79a293558a298f12f2b2e7bc5 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 24 Aug 2026 11:48:34 -0500 Subject: [PATCH 22/22] Allow duplicates and simplify code --- sdks/csharp/src/AssemblyInfo.cs | 3 + sdks/csharp/src/AssemblyInfo.cs.meta | 11 + .../src/EventHandling/EventListeners.cs | 133 +++--- .../csharp/src/SappyIntegration/Extensions.cs | 2 +- .../SappyIntegration/SappyEventListeners.cs | 421 ++---------------- sdks/csharp/tests~/EventListenersTests.cs | 18 +- 6 files changed, 125 insertions(+), 463 deletions(-) create mode 100644 sdks/csharp/src/AssemblyInfo.cs create mode 100644 sdks/csharp/src/AssemblyInfo.cs.meta diff --git a/sdks/csharp/src/AssemblyInfo.cs b/sdks/csharp/src/AssemblyInfo.cs new file mode 100644 index 00000000000..bc7cfcf281c --- /dev/null +++ b/sdks/csharp/src/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("com.clockworklabs.spacetimedbsdk.sappyintegration")] diff --git a/sdks/csharp/src/AssemblyInfo.cs.meta b/sdks/csharp/src/AssemblyInfo.cs.meta new file mode 100644 index 00000000000..c29b17f70a4 --- /dev/null +++ b/sdks/csharp/src/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a80f81d7b4545c7965a34c942583f06 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/src/EventHandling/EventListeners.cs b/sdks/csharp/src/EventHandling/EventListeners.cs index bb558b3ab84..817044aafd8 100644 --- a/sdks/csharp/src/EventHandling/EventListeners.cs +++ b/sdks/csharp/src/EventHandling/EventListeners.cs @@ -4,7 +4,7 @@ namespace SpacetimeDB.EventHandling { - internal class EventListeners : IEventListeners where T : Delegate + internal sealed class EventListeners : IEventListeners where T : Delegate { private const int SmallListenerThreshold = 8; private const int CollisionBucket = -1; @@ -13,23 +13,30 @@ internal class EventListeners : IEventListeners where T : Delegate private int[] _hashes; private T?[] _listeners; - private int _capacity; private int _count; private Dictionary? _indices; private Dictionary>? _collisions; private Stack>? _collisionsPool; - public int Count => _count; + public int Count + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _count; + } - public T this[int index] => _listeners[index]!; + public T this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _listeners[index]!; + } public EventListeners() : this(4) { } public EventListeners(int initialSize) { - _capacity = Math.Max(1, initialSize); - _hashes = new int[_capacity]; - _listeners = new T[_capacity]; + var capacity = Math.Max(1, initialSize); + _hashes = new int[capacity]; + _listeners = new T[capacity]; } public void Add(T listener) @@ -40,8 +47,6 @@ public void Add(T listener) if (_count <= SmallListenerThreshold) { - if (FindLinear(listener) >= 0) return; - AddRaw(hashCode, listener); if (_count > SmallListenerThreshold) @@ -52,19 +57,17 @@ public void Add(T listener) return; } + var newIndex = AddRaw(hashCode, listener); var indices = _indices!; if (!indices.TryGetValue(hashCode, out var index)) { - indices.Add(hashCode, AddRaw(hashCode, listener)); + indices.Add(hashCode, newIndex); return; } if (index != CollisionBucket) { - if (DelegateEquals(_listeners[index]!, listener)) return; - - var newIndex = AddRaw(hashCode, listener); _collisions ??= new Dictionary>(); _collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); indices[hashCode] = CollisionBucket; @@ -72,34 +75,27 @@ public void Add(T listener) } var bucket = _collisions![hashCode]; - - for (var i = 0; i < bucket.Count; i++) - { - if (DelegateEquals(_listeners[bucket[i]]!, listener)) return; - } - - bucket.Add(AddRaw(hashCode, listener)); + bucket.Add(newIndex); } public void Remove(T listener) { if (listener == null || _count <= 0) return; - var hashCode = listener.GetHashCode(); - if (_count <= SmallListenerThreshold) { var index = FindLinear(listener); if (index >= 0) { RemoveAtSwapBackRaw(index); - ClearIndex(); } return; } - var indices = _indices!; + var hashCode = listener.GetHashCode(); + var indices = _indices; + if (indices == null) return; if (!indices.TryGetValue(hashCode, out var mappedIndex)) return; @@ -158,7 +154,6 @@ public void Remove(T listener) } } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private int FindLinear(T listener) { for (var i = 0; i < _count; i++) @@ -169,7 +164,6 @@ private int FindLinear(T listener) return -1; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private int AddRaw(int hashCode, T listener) { EnsureCapacity(); @@ -181,7 +175,6 @@ private int AddRaw(int hashCode, T listener) return index; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RemoveAtSwapBackRaw(int index) { var lastIndex = _count - 1; @@ -197,50 +190,14 @@ private void RemoveAtSwapBackRaw(int index) _count = lastIndex; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private void EnsureCapacity() { - if (_count < _capacity) return; - - _capacity *= 2; - Array.Resize(ref _hashes, _capacity); - Array.Resize(ref _listeners, _capacity); - } - - private void RebuildIndex() - { - if (_indices == null) - { - _indices = new Dictionary(_capacity); - } - else - { - ClearIndex(); - } + var capacity = _listeners.Length; + if (_count < capacity) return; - var indices = _indices; - for (var i = 0; i < _count; i++) - { - var hashCode = _hashes[i]; - - if (!indices.TryGetValue(hashCode, out var existing)) - { - indices.Add(hashCode, i); - continue; - } - - _collisions ??= new Dictionary>(); - - if (existing != CollisionBucket) - { - _collisions[hashCode] = GetCollisionsListFromPool(existing, i); - indices[hashCode] = CollisionBucket; - } - else - { - _collisions[hashCode].Add(i); - } - } + capacity *= 2; + Array.Resize(ref _hashes, capacity); + Array.Resize(ref _listeners, capacity); } private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) @@ -266,7 +223,6 @@ private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) } } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void RemoveBucketSlot(List bucket, int slot) { var lastSlot = bucket.Count - 1; @@ -279,6 +235,42 @@ private static void RemoveBucketSlot(List bucket, int slot) bucket.RemoveAt(lastSlot); } + private void RebuildIndex() + { + if (_indices == null) + { + _indices = new Dictionary(_listeners.Length); + } + else + { + ClearIndex(); + } + + var indices = _indices; + for (var i = 0; i < _count; i++) + { + var hashCode = _hashes[i]; + + if (!indices.TryGetValue(hashCode, out var existing)) + { + indices.Add(hashCode, i); + continue; + } + + _collisions ??= new Dictionary>(); + + if (existing != CollisionBucket) + { + _collisions[hashCode] = GetCollisionsListFromPool(existing, i); + indices[hashCode] = CollisionBucket; + } + else + { + _collisions[hashCode].Add(i); + } + } + } + private void ClearIndex() { _indices?.Clear(); @@ -293,7 +285,6 @@ private void ClearIndex() _collisions.Clear(); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private List GetCollisionsListFromPool(int a, int b) { if (_collisionsPool == null || _collisionsPool.Count <= 0) return new List(2) { a, b }; @@ -314,4 +305,4 @@ private void ReturnCollisionsListToPool(List list) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool DelegateEquals(T a, T b) => Comparer.Equals(a, b); } -} \ No newline at end of file +} diff --git a/sdks/csharp/src/SappyIntegration/Extensions.cs b/sdks/csharp/src/SappyIntegration/Extensions.cs index 1768f424c7b..1ae7f5438eb 100644 --- a/sdks/csharp/src/SappyIntegration/Extensions.cs +++ b/sdks/csharp/src/SappyIntegration/Extensions.cs @@ -27,7 +27,7 @@ public static void RemoveSapTarget(this IEventListeners listeners, SapTarg } else { - listeners.Add(value.Callback); + listeners.Remove(value.Callback); } } } diff --git a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs index 79a3ebc5bd9..7c165f80f97 100644 --- a/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs +++ b/sdks/csharp/src/SappyIntegration/SappyEventListeners.cs @@ -1,413 +1,68 @@ #if SAPPY using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; using Sappy; using SpacetimeDB.EventHandling; +using System.Runtime.CompilerServices; namespace SpacetimeDB.SappyIntegration { public class SappyEventListeners : IEventListeners where T : Delegate { - private readonly TargetCache _cache = new(4); - - public void Add(SapTarget listener) => _cache.Add(listener); - public void Remove(SapTarget listener) => _cache.Remove(listener); + private EventListeners? _eventListeners; + private SapDelegate? _sapDelegate; - public int Count => _cache.Count; - - public T this[int index] => _cache[index]; - - public void Add(T listener) - { - if (listener == null) return; - _cache.Add(listener); - } - - public void Remove(T listener) + public int Count { - if (listener == null) return; - _cache.Remove(listener); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => (_eventListeners?.Count ?? 0) + (_sapDelegate?.Count ?? 0); } - - private sealed class TargetCache + + public T this[int index] { - private const int SmallListenerThreshold = 8; - private const int CollisionBucket = -1; - - private static readonly EqualityComparer Comparer = EqualityComparer.Default; - - private int[] _hashes; - private T?[] _callbacks; - private SapTarget?[] _targets; - private int _capacity; - private int _count; - private Dictionary? _indices; - private Dictionary>? _collisions; - private Stack>? _collisionsPool; - - public int Count => _count; - - public T this[int index] => _callbacks[index]!; - - public TargetCache(int capacity) - { - _capacity = Math.Max(1, capacity); - _hashes = new int[_capacity]; - _callbacks = new T[_capacity]; - _targets = new SapTarget[_capacity]; - } - - public bool Add(T callback) - { - if (callback == null) return false; - - var hashCode = callback.GetHashCode(); - - if (_count <= SmallListenerThreshold) - { - if (FindLinear(callback) >= 0) return false; - - AddRaw(hashCode, callback, new SapTarget(callback)); - - if (_count > SmallListenerThreshold) - { - RebuildIndex(); - } - - return true; - } - - var indices = _indices!; - - if (!indices.TryGetValue(hashCode, out var index)) - { - indices.Add(hashCode, AddRaw(hashCode, callback, new SapTarget(callback))); - return true; - } - - if (index != CollisionBucket) - { - if (DelegateEquals(_callbacks[index]!, callback)) return false; - - var newIndex = AddRaw(hashCode, callback, new SapTarget(callback)); - _collisions ??= new Dictionary>(); - _collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); - indices[hashCode] = CollisionBucket; - return true; - } - - var bucket = _collisions![hashCode]; - - for (var i = 0; i < bucket.Count; i++) - { - if (DelegateEquals(_callbacks[bucket[i]]!, callback)) return false; - } - - bucket.Add(AddRaw(hashCode, callback, new SapTarget(callback))); - return true; - } - - public bool Add(SapTarget target) + get { - if (target == null || target.Callback == null) return false; - - var hashCode = target.HashCode; - var callback = target.Callback; - - if (_count <= SmallListenerThreshold) - { - if (FindLinear(callback) >= 0) return false; - - AddRaw(hashCode, callback, target); - - if (_count > SmallListenerThreshold) - { - RebuildIndex(); - } - - return true; - } - - var indices = _indices!; - - if (!indices.TryGetValue(hashCode, out var index)) - { - indices.Add(hashCode, AddRaw(hashCode, callback, target)); - return true; - } + var eventListeners = _eventListeners; + var eventListenersCount = eventListeners?.Count ?? 0; - if (index != CollisionBucket) + if ((uint)index < (uint)eventListenersCount) { - if (DelegateEquals(_callbacks[index]!, callback)) return false; - - var newIndex = AddRaw(hashCode, callback, target); - _collisions ??= new Dictionary>(); - _collisions[hashCode] = GetCollisionsListFromPool(index, newIndex); - indices[hashCode] = CollisionBucket; - return true; + return eventListeners![index]; } - var bucket = _collisions![hashCode]; + var sapDelegate = _sapDelegate; + var sapIndex = index - eventListenersCount; - for (var i = 0; i < bucket.Count; i++) + if (sapDelegate != null && (uint)sapIndex < (uint)sapDelegate.Count) { - if (DelegateEquals(_callbacks[bucket[i]]!, callback)) return false; + return sapDelegate[sapIndex]; } - bucket.Add(AddRaw(hashCode, callback, target)); - return true; - } - - public bool Remove(T callback) - { - if (callback == null || _count <= 0) return false; - - var hashCode = callback.GetHashCode(); - return Remove(hashCode, callback); - } - - public bool Remove(SapTarget target) - { - if (target == null || target.Callback == null || _count <= 0) return false; - return Remove(target.HashCode, target.Callback); - } - - private bool Remove(int hashCode, T callback) - { - if (_count <= SmallListenerThreshold) - { - var index = FindLinear(callback); - if (index >= 0) - { - RemoveAtSwapBackRaw(index); - ClearIndex(); - return true; - } - - return false; - } - - var indices = _indices!; - - if (!indices.TryGetValue(hashCode, out var mappedIndex)) return false; - - var removeIndex = -1; - - if (mappedIndex != CollisionBucket) - { - if (!DelegateEquals(_callbacks[mappedIndex]!, callback)) return false; - - removeIndex = mappedIndex; - indices.Remove(hashCode); - } - else - { - var bucket = _collisions![hashCode]; - - for (var i = 0; i < bucket.Count; i++) - { - var candidate = bucket[i]; - if (!DelegateEquals(_callbacks[candidate]!, callback)) continue; - - removeIndex = candidate; - RemoveBucketSlot(bucket, i); - - if (bucket.Count == 1) - { - indices[hashCode] = bucket[0]; - _collisions.Remove(hashCode); - ReturnCollisionsListToPool(bucket); - } - else if (bucket.Count == 0) - { - indices.Remove(hashCode); - _collisions.Remove(hashCode); - ReturnCollisionsListToPool(bucket); - } - - break; - } - - if (removeIndex < 0) return false; - } - - var movedFrom = _count - 1; - var movedHashCode = _hashes[movedFrom]; - - RemoveAtSwapBackRaw(removeIndex); - - if (_count <= SmallListenerThreshold) - { - ClearIndex(); - } - else if (removeIndex != movedFrom) - { - UpdateMovedIndex(movedHashCode, movedFrom, removeIndex); - } - - return true; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private int FindLinear(T callback) - { - for (var i = 0; i < _count; i++) - { - if (DelegateEquals(_callbacks[i]!, callback)) return i; - } - - return -1; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private int AddRaw(int hashCode, T callback, SapTarget target) - { - EnsureCapacity(); - - var index = _count; - _hashes[index] = hashCode; - _callbacks[index] = callback; - _targets[index] = target; - _count++; - return index; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RemoveAtSwapBackRaw(int index) - { - var lastIndex = _count - 1; - - if (index != lastIndex) - { - _hashes[index] = _hashes[lastIndex]; - _callbacks[index] = _callbacks[lastIndex]; - _targets[index] = _targets[lastIndex]; - } - - _hashes[lastIndex] = 0; - _callbacks[lastIndex] = null; - _targets[lastIndex] = null; - _count = lastIndex; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void EnsureCapacity() - { - if (_count < _capacity) return; - - _capacity *= 2; - Array.Resize(ref _hashes, _capacity); - Array.Resize(ref _callbacks, _capacity); - Array.Resize(ref _targets, _capacity); - } - - private void RebuildIndex() - { - if (_indices == null) - { - _indices = new Dictionary(_capacity); - } - else - { - ClearIndex(); - } - - var indices = _indices; - for (var i = 0; i < _count; i++) - { - var hashCode = _hashes[i]; - - if (!indices.TryGetValue(hashCode, out var existing)) - { - indices.Add(hashCode, i); - continue; - } - - _collisions ??= new Dictionary>(); - - if (existing != CollisionBucket) - { - _collisions[hashCode] = GetCollisionsListFromPool(existing, i); - indices[hashCode] = CollisionBucket; - } - else - { - _collisions[hashCode].Add(i); - } - } - } - - private void UpdateMovedIndex(int hashCode, int oldIndex, int newIndex) - { - var indices = _indices!; - var mappedIndex = indices[hashCode]; - - if (mappedIndex != CollisionBucket) - { - indices[hashCode] = newIndex; - return; - } - - var bucket = _collisions![hashCode]; - - for (var i = 0; i < bucket.Count; i++) - { - if (bucket[i] == oldIndex) - { - bucket[i] = newIndex; - return; - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void RemoveBucketSlot(List bucket, int slot) - { - var lastSlot = bucket.Count - 1; - - if (slot != lastSlot) - { - bucket[slot] = bucket[lastSlot]; - } - - bucket.RemoveAt(lastSlot); - } - - private void ClearIndex() - { - _indices?.Clear(); - - if (_collisions == null) return; - - foreach (var collisions in _collisions.Values) - { - ReturnCollisionsListToPool(collisions); - } - - _collisions.Clear(); + throw new IndexOutOfRangeException(); } + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private List GetCollisionsListFromPool(int a, int b) - { - if (_collisionsPool == null || _collisionsPool.Count <= 0) return new List(2) { a, b }; + public void Add(SapTarget listener) + { + if (listener == null) return; + (_sapDelegate ??= new SapDelegate()).Add(listener); + } - var list = _collisionsPool.Pop(); - list.Add(a); - list.Add(b); - return list; - } + public void Remove(SapTarget listener) + { + if (listener == null || _sapDelegate == null) return; + _sapDelegate.Remove(listener); + } - private void ReturnCollisionsListToPool(List list) - { - list.Clear(); - _collisionsPool ??= new Stack>(); - _collisionsPool.Push(list); - } + public void Add(T listener) + { + if (listener == null) return; + (_eventListeners ??= new EventListeners()).Add(listener); + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool DelegateEquals(T a, T b) => Comparer.Equals(a, b); + public void Remove(T listener) + { + if (listener == null || _eventListeners == null) return; + _eventListeners.Remove(listener); } } } diff --git a/sdks/csharp/tests~/EventListenersTests.cs b/sdks/csharp/tests~/EventListenersTests.cs index f28d3dc424f..d867cd91872 100644 --- a/sdks/csharp/tests~/EventListenersTests.cs +++ b/sdks/csharp/tests~/EventListenersTests.cs @@ -5,9 +5,9 @@ public class EventListenersTests { [Fact] - public void BasicEventListenersDeduplicatesRemovesAndResubscribes() + public void EventListenersAllowDuplicatesAndRemoveOneSubscriptionAtATime() { - var eventListeners = new BasicEventListeners(); + var eventListeners = new EventListeners(); var callCount = 0; var listeners = new Action[12]; @@ -18,26 +18,28 @@ public void BasicEventListenersDeduplicatesRemovesAndResubscribes() } eventListeners.Add(listeners[3]); - Assert.Equal(listeners.Length, eventListeners.Count); + Assert.Equal(listeners.Length + 1, eventListeners.Count); InvokeAll(eventListeners); - Assert.Equal(listeners.Length, callCount); + Assert.Equal(listeners.Length + 1, callCount); eventListeners.Remove(listeners[3]); eventListeners.Remove(listeners[9]); - eventListeners.Remove(listeners[3]); - Assert.Equal(listeners.Length - 2, eventListeners.Count); + Assert.Equal(listeners.Length - 1, eventListeners.Count); callCount = 0; InvokeAll(eventListeners); - Assert.Equal(listeners.Length - 2, callCount); + Assert.Equal(listeners.Length - 1, callCount); + + eventListeners.Remove(listeners[3]); + Assert.Equal(listeners.Length - 2, eventListeners.Count); eventListeners.Add(listeners[3]); eventListeners.Add(listeners[9]); Assert.Equal(listeners.Length, eventListeners.Count); } - private static void InvokeAll(BasicEventListeners listeners) + private static void InvokeAll(EventListeners listeners) { for (var i = listeners.Count - 1; i >= 0; i--) {