From ff2a5a9a855b51365bdd9ff2142c86d95503d466 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 3 Aug 2026 11:17:42 -0500 Subject: [PATCH 01/26] 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 063d45bfdbe..3608d7611ba 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -406,11 +406,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; @@ -575,33 +583,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); } } @@ -615,23 +641,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 f19f842f354d56b2e8e750faddb4dc03d78ae474 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 3 Aug 2026 11:22:17 -0500 Subject: [PATCH 02/26] 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 9d9d000d4bea298ff5567cdb3ca3ee8ff329bb8d Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 11 Aug 2026 12:42:11 -0500 Subject: [PATCH 03/26] 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 3608d7611ba..ce1020e865d 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -212,18 +212,8 @@ static RemoteTableHandleBase() // 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); @@ -514,14 +504,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 { @@ -532,7 +522,7 @@ void IRemoteTableHandle.Apply(IEventContext context, IParsedTableUpdate parsedTa { if (oldValue is Row oldRow) { - OnInternalDeleteHandler.Invoke(oldRow); + OnInternalDelete?.Invoke(oldRow); } else { @@ -542,7 +532,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 8ae335db8f0..c3e27c9f812 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 41e9465356fba068c2366bcd908a08ee24589691 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 11 Aug 2026 13:25:20 -0500 Subject: [PATCH 04/26] 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 8e5f99d5bf63129b8c51c72acfccb3d5082ada94 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 08:33:35 -0500 Subject: [PATCH 05/26] 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 702c849c6b004596c41251fce117aa7f78512c6e Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 14:44:16 -0500 Subject: [PATCH 06/26] 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 ce1020e865d..231d3bf2730 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -573,8 +573,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 @@ -597,8 +597,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 ae69e672c26f1bf7fea6827f450cdd0ff1e157cd Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 14:48:56 -0500 Subject: [PATCH 07/26] 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 231d3bf2730..7316118fd1b 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -397,7 +397,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; @@ -632,7 +632,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; @@ -646,7 +646,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; @@ -660,7 +660,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 4c89d459e54a26c62cef66334ad35cbb22c7b9a0 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 14:53:36 -0500 Subject: [PATCH 08/26] 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 b01d2fbf7966565617d4830b89397e06fcc1979a Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 12 Aug 2026 15:20:55 -0500 Subject: [PATCH 09/26] 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 40049fbd638e3178d8a675aeffb72679ef731a60 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 13 Aug 2026 10:03:46 -0500 Subject: [PATCH 10/26] 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 d31f64879fa3e000c964404bb539f768a1054510 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 13 Aug 2026 15:16:29 -0500 Subject: [PATCH 11/26] 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 7316118fd1b..becffbf261b 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -395,21 +395,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; @@ -573,49 +569,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); } } } @@ -631,46 +603,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 de537c3a8a6d5fa88e9c1aaf42357a49af58a0b6 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 09:22:50 -0500 Subject: [PATCH 12/26] 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 eaa8e877a68d57ff265c45e89087374800ab18d7 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 12:51:14 -0500 Subject: [PATCH 13/26] 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 199e48572e6288a73bbaf4ad7a7d4dfac0f18682 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 14:07:48 -0500 Subject: [PATCH 14/26] 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 9e5d80db694d32645826a6110179fe0d6e1301f6 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 14:54:30 -0500 Subject: [PATCH 15/26] 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 7bc1d6e8280da1b5fdfc932efc14ba63f929f1cf Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 14 Aug 2026 15:15:59 -0500 Subject: [PATCH 16/26] 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 becffbf261b..22d593e4522 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -573,9 +573,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); } } } @@ -585,9 +586,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 89b4e18d85aa1c993f7619c40313b20b3c7fac8b Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 17 Aug 2026 13:11:03 -0500 Subject: [PATCH 17/26] 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 22d593e4522..fabf1619c94 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -400,8 +400,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; @@ -569,11 +569,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); @@ -582,11 +625,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); @@ -607,8 +693,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; @@ -617,8 +703,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; @@ -627,8 +713,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 bd7a03cc62e49590a8abdcf3f95dc760d8ddae5a Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 17 Aug 2026 14:12:27 -0500 Subject: [PATCH 18/26] 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 fabf1619c94..53d54612987 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -569,7 +569,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; @@ -582,7 +582,7 @@ public CustomRowEventHandler() { if (!_useNativeDispatch) { - _indexedListeners = EventListenersProvider.Create(); + _indexedListeners = Backend.Create(); } } @@ -625,7 +625,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; @@ -638,7 +638,7 @@ public CustomUpdateEventHandler() { if (!_useNativeDispatch) { - _indexedListeners = EventListenersProvider.Create(); + _indexedListeners = Backend.Create(); } } From b33f454c3899e92c1cd41d09702462f745837721 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 17 Aug 2026 14:19:37 -0500 Subject: [PATCH 19/26] 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 1c43c8a7518179b8bc2d87f1c6ea1ab7d4129993 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 18 Aug 2026 08:42:52 -0500 Subject: [PATCH 20/26] 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 9f26359b7993b7797b044fdfefd2c024c543ae14 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 19 Aug 2026 11:48:06 -0500 Subject: [PATCH 21/26] 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 0505330beeec6558eca004449c06d1ad816f81e4 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 24 Aug 2026 11:48:34 -0500 Subject: [PATCH 22/26] 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--) { From aabf20f8f8236af60d3679fa63287b65b5555cb4 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 3 Sep 2026 10:45:45 -0500 Subject: [PATCH 23/26] Add documentation --- .../00600-clients/00600-csharp-reference.md | 52 +++++ .../event-handling-benchmarks/README.md | 10 +- .../client/Program.cs | 181 ++++++++++++++++-- 3 files changed, 219 insertions(+), 24 deletions(-) diff --git a/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md b/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md index e6de19c91d7..40941bcbd2d 100644 --- a/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md @@ -42,6 +42,7 @@ Before diving into the reference, you may want to review: | [`ErrorContext` type](#type-errorcontext) | Implements [`IDbContext`](#interface-idbcontext) for subscription error callbacks. | | [Query Builder API](#query-builder-api) | Type-safe query builder for typed subscription queries. | | [Access the client cache](#access-the-client-cache) | Access to your local view of the database. | +| [Configure event dispatch](#configure-event-dispatch) | Choose native C# events or a custom event listener backend. | | [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | | [Identify a client](#identify-a-client) | Types for identifying users and client connections. | @@ -1035,6 +1036,57 @@ The `OnUpdate` callback runs whenever an already-resident row in the client cach See [the quickstart](../../00100-intro/00200-quickstarts/00600-c-sharp.md) for examples of registering and unregistering row callbacks. +### Configure event dispatch + +By default, the C# SDK stores table row callbacks as regular native C# events. For most applications, no extra setup is required: + +```csharp +conn.Db.User.OnInsert += OnUserInsert; +conn.Db.User.OnInsert -= OnUserInsert; +``` + +Native events are simple, idiomatic, and should be your default choice unless profiling shows that event subscription management is a problem in your application. + +If your client frequently adds and removes many row callbacks, the cost of native multicast delegate updates can become noticeable. For those cases, the SDK can use a custom event listener backend instead: + +```csharp +using SpacetimeDB.EventHandling; + +Backend.UseCustomListeners(); + +var conn = DbConnection.Builder() + .WithUri("http://localhost:3000") + .WithDatabaseName("my-database") + .Build(); +``` + +Call `Backend.UseCustomListeners()` before creating the generated `DbConnection`. Table handles capture the selected backend when they are constructed, so changing the backend later does not update existing handles. + +The default custom backend keeps listeners in an indexed collection. It is useful when you have many listener removals, duplicate subscriptions, or integration code that attaches and detaches callbacks aggressively. Registering and unregistering callbacks still uses the same generated `OnInsert`, `OnDelete`, and `OnUpdate` event APIs. + +Reducer result events, such as `conn.Reducers.OnSendMessage`, are always regular C# events. + +You can also provide your own listener implementation: + +```csharp +using SpacetimeDB.EventHandling; + +Backend.UseCustomListeners(new MyEventListenersFactory()); +``` + +The factory must implement `IEventListenersFactory` and return an `IEventListeners` for each delegate type. + +If your project includes [Sappy](https://github.com/clockworklabs/SappyEvents/), the SDK can use Sappy-backed listener storage: + +```csharp +using SpacetimeDB.EventHandling; +using SpacetimeDB.SappyIntegration; + +Backend.UseCustomListeners(new SappyEventListenersFactory()); +``` + +Use the Sappy backend only in projects that already reference Sappy. It is intended for applications that have standardized on Sappy's event/listener model; it is not required for normal C# or Unity clients. + ### Unique constraint index access For each unique constraint on a table, its table handle has a property which is a unique index handle and whose name is the unique column name. This unique index handle has a method `.Find(Column value)`. If a `Row` with `value` in the unique column is resident in the client cache, `.Find` returns it. Otherwise it returns null. diff --git a/sdks/csharp/examples~/event-handling-benchmarks/README.md b/sdks/csharp/examples~/event-handling-benchmarks/README.md index 62c72beffdb..82e52caaf37 100644 --- a/sdks/csharp/examples~/event-handling-benchmarks/README.md +++ b/sdks/csharp/examples~/event-handling-benchmarks/README.md @@ -1,6 +1,6 @@ # C# event handling benchmarks -This benchmark client measures table event subscription, update dispatch, unsubscription, and resubscription against a real SpacetimeDB module. +This benchmark client compares native C# event dispatch with the SDK custom event listener backend. It measures elapsed time and process-wide allocated bytes for 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. @@ -14,13 +14,15 @@ 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`. +- `SPACETIMEDB_EVENT_BACKEND`: `all`, `native`, `custom`, or `sappy`. `all` runs `native` and `custom`. +- `SPACETIMEDB_BENCHMARK_WARMUPS`: warmup runs per scenario/backend, default `1`. +- `SPACETIMEDB_BENCHMARK_ITERATIONS`: measured runs per scenario/backend, default `3`. 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. +- `sappy`: Sappy-backed custom listener dispatch. This path is only run when explicitly requested and 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: @@ -29,3 +31,5 @@ Scenarios: - Many subscriptions, many updates. - Many subscriptions, some updates, many unsubscriptions. - Many subscriptions, some updates, many unsubscriptions, many resubscriptions, some updates. + +The update timings and allocation counts include reducer calls, server round trips, client frame ticks, row decoding, and listener dispatch. Allocation counts use `GC.GetTotalAllocatedBytes(precise: true)`, so they include allocations from background client work as well as main-thread dispatch. Compare native and custom runs on the same machine/server rather than treating the values as isolated in-process dispatch costs. diff --git a/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs b/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs index 5bfb3615ba6..431329eb400 100644 --- a/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs +++ b/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs @@ -10,9 +10,15 @@ const string DefaultHost = "http://localhost:3000"; const string DefaultDatabase = "event-handling-bench"; +const int DefaultWarmups = 1; +const int DefaultIterations = 3; + 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 warmups = ParseNonNegativeEnv("SPACETIMEDB_BENCHMARK_WARMUPS", DefaultWarmups); +var iterations = ParsePositiveEnv("SPACETIMEDB_BENCHMARK_ITERATIONS", DefaultIterations); +var summaries = new List(); var scenarios = new[] { @@ -21,14 +27,19 @@ 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), + new Scenario("many-subscriptions-some-updates-many-unsubscriptions-resubscriptions-some-updates", Subscriptions: 1_000, FirstUpdates: 1_000, Unsubscriptions: 1_000, Resubscriptions: 1_000, SecondUpdates: 1_000), }; 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("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); +Console.WriteLine($"Warmups: {warmups}"); +Console.WriteLine($"Iterations: {iterations}"); +Console.WriteLine(); +Console.WriteLine("Per-iteration timings:"); +Console.WriteLine("| Backend | Scenario | Iteration | Subscriptions | First updates | Unsubscriptions | Resubscriptions | Second updates | Subscribe ms | Subscribe bytes | First updates ms | First updates bytes | Unsubscribe ms | Unsubscribe bytes | Resubscribe ms | Resubscribe bytes | Second updates ms | Second updates bytes | Listener calls |"); +Console.WriteLine("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); foreach (var backendKind in ExpandBackends(backend)) { @@ -36,15 +47,42 @@ 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} |" - ); + for (var i = 0; i < warmups; i++) + { + RunOne(host, database, backendKind, scenario); + } + + var results = new BenchmarkResult[iterations]; + for (var i = 0; i < iterations; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + results[i] = RunOne(host, database, backendKind, scenario); + PrintResult(backendKind, scenario, i + 1, results[i]); + } + + summaries.Add(BenchmarkSummary.From(backendKind, scenario, results)); } } +Console.WriteLine(); +Console.WriteLine("Summary timings:"); +Console.WriteLine("| Backend | Scenario | Subscribe ms avg | Subscribe bytes avg | First updates ms avg | First updates bytes avg | Unsubscribe ms avg | Unsubscribe bytes avg | Resubscribe ms avg | Resubscribe bytes avg | Second updates ms avg | Second updates bytes avg |"); +Console.WriteLine("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); +foreach (var summary in summaries) +{ + Console.WriteLine( + $"| {summary.Backend} | {summary.Scenario.Name} | " + + $"{summary.Subscribe.MeanMilliseconds:F3} | {summary.Subscribe.MeanAllocatedBytes:F0} | " + + $"{summary.FirstUpdates.MeanMilliseconds:F3} | {summary.FirstUpdates.MeanAllocatedBytes:F0} | " + + $"{summary.Unsubscribe.MeanMilliseconds:F3} | {summary.Unsubscribe.MeanAllocatedBytes:F0} | " + + $"{summary.Resubscribe.MeanMilliseconds:F3} | {summary.Resubscribe.MeanAllocatedBytes:F0} | " + + $"{summary.SecondUpdates.MeanMilliseconds:F3} | {summary.SecondUpdates.MeanAllocatedBytes:F0} |" + ); +} + static BackendKind ParseBackend(string value) => value.Trim().ToLowerInvariant() switch { @@ -65,9 +103,6 @@ static IEnumerable ExpandBackends(BackendKind backend) yield return BackendKind.Native; yield return BackendKind.Custom; -#if SAPPY - yield return BackendKind.Sappy; -#endif } static void ConfigureBackend(BackendKind backend) @@ -92,6 +127,56 @@ static void ConfigureBackend(BackendKind backend) } } +static BenchmarkResult RunOne(string host, string database, BackendKind backendKind, Scenario scenario) +{ + using var runner = new BenchmarkRunner(host, database, backendKind, scenario); + return runner.Run(); +} + +static void PrintResult(BackendKind backendKind, Scenario scenario, int iteration, BenchmarkResult result) +{ + Console.WriteLine( + $"| {backendKind} | {scenario.Name} | {iteration} | {scenario.Subscriptions} | {scenario.FirstUpdates} | {scenario.Unsubscriptions} | {scenario.Resubscriptions} | {scenario.SecondUpdates} | " + + $"{result.Subscribe.Elapsed.TotalMilliseconds:F3} | {result.Subscribe.AllocatedBytes} | " + + $"{result.FirstUpdates.Elapsed.TotalMilliseconds:F3} | {result.FirstUpdates.AllocatedBytes} | " + + $"{result.Unsubscribe.Elapsed.TotalMilliseconds:F3} | {result.Unsubscribe.AllocatedBytes} | " + + $"{result.Resubscribe.Elapsed.TotalMilliseconds:F3} | {result.Resubscribe.AllocatedBytes} | " + + $"{result.SecondUpdates.Elapsed.TotalMilliseconds:F3} | {result.SecondUpdates.AllocatedBytes} | {result.ListenerCalls} |" + ); +} + +static int ParseNonNegativeEnv(string name, int defaultValue) +{ + var value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (int.TryParse(value, out var parsed) && parsed >= 0) + { + return parsed; + } + + throw new ArgumentOutOfRangeException(name, value, "Expected a non-negative integer."); +} + +static int ParsePositiveEnv(string name, int defaultValue) +{ + var value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (int.TryParse(value, out var parsed) && parsed > 0) + { + return parsed; + } + + throw new ArgumentOutOfRangeException(name, value, "Expected a positive integer."); +} + internal enum BackendKind { All, @@ -110,14 +195,66 @@ internal sealed record Scenario( ); internal readonly record struct BenchmarkResult( - TimeSpan Subscribe, - TimeSpan FirstUpdates, - TimeSpan Unsubscribe, - TimeSpan Resubscribe, - TimeSpan SecondUpdates, + Measurement Subscribe, + Measurement FirstUpdates, + Measurement Unsubscribe, + Measurement Resubscribe, + Measurement SecondUpdates, long ListenerCalls ); +internal readonly record struct Measurement(TimeSpan Elapsed, long AllocatedBytes) +{ + public static Measurement Zero { get; } = new(TimeSpan.Zero, 0); +} + +internal readonly record struct Stats( + double MeanMilliseconds, + double MinMilliseconds, + double MaxMilliseconds, + double MeanAllocatedBytes, + long MinAllocatedBytes, + long MaxAllocatedBytes +) +{ + public static Stats From(IEnumerable values) + { + var measurements = values.ToArray(); + var milliseconds = measurements.Select(value => value.Elapsed.TotalMilliseconds).ToArray(); + var allocatedBytes = measurements.Select(value => value.AllocatedBytes).ToArray(); + return new Stats( + milliseconds.Average(), + milliseconds.Min(), + milliseconds.Max(), + allocatedBytes.Average(), + allocatedBytes.Min(), + allocatedBytes.Max() + ); + } +} + +internal readonly record struct BenchmarkSummary( + BackendKind Backend, + Scenario Scenario, + Stats Subscribe, + Stats FirstUpdates, + Stats Unsubscribe, + Stats Resubscribe, + Stats SecondUpdates +) +{ + public static BenchmarkSummary From(BackendKind backend, Scenario scenario, BenchmarkResult[] results) => + new( + backend, + scenario, + Stats.From(results.Select(r => r.Subscribe)), + Stats.From(results.Select(r => r.FirstUpdates)), + Stats.From(results.Select(r => r.Unsubscribe)), + Stats.From(results.Select(r => r.Resubscribe)), + Stats.From(results.Select(r => r.SecondUpdates)) + ); +} + internal static class BenchmarkSettings { public const int TimeoutSeconds = 120; @@ -193,7 +330,7 @@ public BenchmarkResult Run() }); var activeAfterResubscribe = _scenario.Subscriptions - _scenario.Unsubscriptions + _scenario.Resubscriptions; - var secondUpdates = _scenario.SecondUpdates > 0 ? TimeUpdates(_scenario.SecondUpdates, activeAfterResubscribe) : TimeSpan.Zero; + var secondUpdates = _scenario.SecondUpdates > 0 ? TimeUpdates(_scenario.SecondUpdates, activeAfterResubscribe) : Measurement.Zero; return new BenchmarkResult( subscribe, @@ -247,11 +384,11 @@ private void Subscribe() TickUntil(() => _subscriptionApplied, "subscription applied"); } - private TimeSpan TimeUpdates(int updateCount, int activeSubscriptions) + private Measurement TimeUpdates(int updateCount, int activeSubscriptions) { if (updateCount <= 0) { - return TimeSpan.Zero; + return Measurement.Zero; } var expectedCalls = activeSubscriptions * updateCount; @@ -285,12 +422,14 @@ private void RecordInsert() Interlocked.Increment(ref _listenerCalls); } - private TimeSpan Time(Action action) + private Measurement Time(Action action) { + var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); var stopwatch = Stopwatch.StartNew(); action(); stopwatch.Stop(); - return stopwatch.Elapsed; + var allocatedAfter = GC.GetTotalAllocatedBytes(precise: true); + return new Measurement(stopwatch.Elapsed, allocatedAfter - allocatedBefore); } private void TickUntil(Func complete, string phase) From 37bfc79af8c1293bfdd9a66eaa879d5086f1e014 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 3 Sep 2026 11:46:01 -0500 Subject: [PATCH 24/26] Improve documentation --- .../00600-clients/00600-csharp-reference.md | 17 +++--- .../client/Program.cs | 53 +++++++++++++++++-- sdks/csharp/src/Table.cs | 4 +- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md b/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md index 40941bcbd2d..626a7d7af62 100644 --- a/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md @@ -1066,26 +1066,25 @@ The default custom backend keeps listeners in an indexed collection. It is usefu Reducer result events, such as `conn.Reducers.OnSendMessage`, are always regular C# events. -You can also provide your own listener implementation: +If your project includes [Sappy](https://github.com/clockworklabs/SappyEvents/), the SDK can use Sappy-backed listener storage: ```csharp using SpacetimeDB.EventHandling; +using SpacetimeDB.SappyIntegration; -Backend.UseCustomListeners(new MyEventListenersFactory()); +Backend.UseCustomListeners(new SappyEventListenersFactory()); ``` -The factory must implement `IEventListenersFactory` and return an `IEventListeners` for each delegate type. +Use the Sappy backend only in projects that already reference Sappy. It is intended for applications that have standardized on Sappy's event/listener model; it is not required for normal C# or Unity clients. -If your project includes [Sappy](https://github.com/clockworklabs/SappyEvents/), the SDK can use Sappy-backed listener storage: +For Sappy-backed table callbacks, register and unregister generated Sappy targets through the listener accessors instead of using normal C# event syntax: ```csharp -using SpacetimeDB.EventHandling; -using SpacetimeDB.SappyIntegration; - -Backend.UseCustomListeners(new SappyEventListenersFactory()); +conn.Db.User.OnInsertListeners.AddSapTarget(Sappy.OnUserInsert); +conn.Db.User.OnInsertListeners.RemoveSapTarget(Sappy.OnUserInsert); ``` -Use the Sappy backend only in projects that already reference Sappy. It is intended for applications that have standardized on Sappy's event/listener model; it is not required for normal C# or Unity clients. +Use the matching listener accessor for each row callback: `OnInsertListeners`, `OnDeleteListeners`, and `OnUpdateListeners`. This lets Sappy manage the callback target directly, which is required for the Sappy backend to behave correctly and avoid unnecessary delegate-management overhead. ### Unique constraint index access diff --git a/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs b/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs index 431329eb400..a034dc117dd 100644 --- a/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs +++ b/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs @@ -5,6 +5,7 @@ using SpacetimeDB.Types; using ExampleDataInsertHandler = SpacetimeDB.RemoteTableHandleBase.RowEventHandler; #if SAPPY +using Sappy; using SpacetimeDB.SappyIntegration; #endif @@ -269,6 +270,9 @@ internal sealed class BenchmarkRunner : IDisposable private readonly Scenario _scenario; private readonly ExampleDataInsertHandler[] _listeners; private readonly Listener[] _listenerTargets; +#if SAPPY + private readonly SapTarget[]? _sapTargets; +#endif private readonly object _lock = new(); private DbConnection _conn = null!; private SubscriptionHandle? _subscription; @@ -287,6 +291,12 @@ public BenchmarkRunner(string host, string database, BackendKind backend, Scenar _scenario = scenario; _listeners = new ExampleDataInsertHandler[scenario.Subscriptions]; _listenerTargets = new Listener[scenario.Subscriptions]; +#if SAPPY + if (backend == BackendKind.Sappy) + { + _sapTargets = new SapTarget[scenario.Subscriptions]; + } +#endif var idBase = unchecked((uint)HashCode.Combine(Environment.ProcessId, DateTime.UtcNow.Ticks, backend, scenario.Name)); _nextId = idBase == 0 ? 1 : idBase; @@ -295,6 +305,12 @@ public BenchmarkRunner(string host, string database, BackendKind backend, Scenar { _listenerTargets[i] = new Listener(this); _listeners[i] = _listenerTargets[i].OnExampleDataInsert; +#if SAPPY + if (_sapTargets != null) + { + _sapTargets[i] = _listenerTargets[i].Sappy.OnExampleDataInsert; + } +#endif } } @@ -305,9 +321,9 @@ public BenchmarkResult Run() var subscribe = Time(() => { - foreach (var listener in _listeners) + for (var i = 0; i < _listeners.Length; i++) { - _conn.Db.ExampleData.OnInsert += listener; + AddListener(i); } }); @@ -317,7 +333,7 @@ public BenchmarkResult Run() { for (var i = 0; i < _scenario.Unsubscriptions; i++) { - _conn.Db.ExampleData.OnInsert -= _listeners[i]; + RemoveListener(i); } }); @@ -325,7 +341,7 @@ public BenchmarkResult Run() { for (var i = 0; i < _scenario.Resubscriptions; i++) { - _conn.Db.ExampleData.OnInsert += _listeners[i]; + AddListener(i); } }); @@ -342,6 +358,30 @@ public BenchmarkResult Run() ); } + private void AddListener(int index) + { +#if SAPPY + if (_backend == BackendKind.Sappy) + { + _conn.Db.ExampleData.OnInsertListeners.AddSapTarget(_sapTargets![index]); + return; + } +#endif + _conn.Db.ExampleData.OnInsert += _listeners[index]; + } + + private void RemoveListener(int index) + { +#if SAPPY + if (_backend == BackendKind.Sappy) + { + _conn.Db.ExampleData.OnInsertListeners.RemoveSapTarget(_sapTargets![index]); + return; + } +#endif + _conn.Db.ExampleData.OnInsert -= _listeners[index]; + } + private void Connect() { _conn = RegressionTestHarness.ConnectToDatabase( @@ -475,7 +515,7 @@ public void Dispose() _conn?.Disconnect(); } - private sealed class Listener + private sealed partial class Listener { private readonly BenchmarkRunner _runner; @@ -484,6 +524,9 @@ public Listener(BenchmarkRunner runner) _runner = runner; } +#if SAPPY + [SapTarget(typeof(ExampleDataInsertHandler))] +#endif public void OnExampleDataInsert(EventContext ctx, ExampleData row) { _runner.RecordInsert(); diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 53d54612987..3df8c6b64cc 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -575,7 +575,7 @@ protected class CustomRowEventHandler 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." + "Use SpacetimeDB.EventHandling.Backend.UseCustomListeners() before creating table handles." ); public CustomRowEventHandler() @@ -631,7 +631,7 @@ protected class CustomUpdateEventHandler 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." + "Use SpacetimeDB.EventHandling.Backend.UseCustomListeners() before creating table handles." ); public CustomUpdateEventHandler() From e8f48ea37d177054b2b7db94f8a7f365e57ef994 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 3 Sep 2026 12:29:27 -0500 Subject: [PATCH 25/26] Make documentation more explicit --- .../00600-clients/00600-csharp-reference.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md b/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md index 626a7d7af62..7ce7403a429 100644 --- a/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md @@ -1050,9 +1050,9 @@ Native events are simple, idiomatic, and should be your default choice unless pr If your client frequently adds and removes many row callbacks, the cost of native multicast delegate updates can become noticeable. For those cases, the SDK can use a custom event listener backend instead: ```csharp -using SpacetimeDB.EventHandling; +using SpacetimeDB; -Backend.UseCustomListeners(); +SpacetimeDB.EventHandling.Backend.UseCustomListeners(); var conn = DbConnection.Builder() .WithUri("http://localhost:3000") @@ -1069,10 +1069,9 @@ Reducer result events, such as `conn.Reducers.OnSendMessage`, are always regular If your project includes [Sappy](https://github.com/clockworklabs/SappyEvents/), the SDK can use Sappy-backed listener storage: ```csharp -using SpacetimeDB.EventHandling; using SpacetimeDB.SappyIntegration; -Backend.UseCustomListeners(new SappyEventListenersFactory()); +SpacetimeDB.EventHandling.Backend.UseCustomListeners(new SappyEventListenersFactory()); ``` Use the Sappy backend only in projects that already reference Sappy. It is intended for applications that have standardized on Sappy's event/listener model; it is not required for normal C# or Unity clients. From b9ce9ba76aae9934cea7ca45c028ce305437b6f7 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 3 Sep 2026 12:48:02 -0500 Subject: [PATCH 26/26] Move benchmark under tests~ --- .../event-handling-benchmarks/README.md | 7 ++++--- .../event-handling-benchmarks/client/Program.cs | 0 .../event-handling-benchmarks/client/client.csproj | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) rename sdks/csharp/{examples~ => tests~}/event-handling-benchmarks/README.md (82%) rename sdks/csharp/{examples~ => tests~}/event-handling-benchmarks/client/Program.cs (100%) rename sdks/csharp/{examples~ => tests~}/event-handling-benchmarks/client/client.csproj (66%) diff --git a/sdks/csharp/examples~/event-handling-benchmarks/README.md b/sdks/csharp/tests~/event-handling-benchmarks/README.md similarity index 82% rename from sdks/csharp/examples~/event-handling-benchmarks/README.md rename to sdks/csharp/tests~/event-handling-benchmarks/README.md index 82e52caaf37..576e729bf99 100644 --- a/sdks/csharp/examples~/event-handling-benchmarks/README.md +++ b/sdks/csharp/tests~/event-handling-benchmarks/README.md @@ -6,8 +6,8 @@ It reuses the C# regression-test module and generated bindings. Publish that mod ```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 +spacetime publish --module-path sdks/csharp/examples~/regression-tests/server event-handling-bench +dotnet run -c Release --project sdks/csharp/tests~/event-handling-benchmarks/client/client.csproj ``` Environment variables: @@ -22,7 +22,7 @@ Backends: - `native`: native C# multicast delegate dispatch. - `custom`: SDK custom indexed listener dispatch. -- `sappy`: Sappy-backed custom listener dispatch. This path is only run when explicitly requested and 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. +- `sappy`: Sappy-backed custom listener dispatch. This path is only run when explicitly requested and 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. The Sappy benchmark registers generated Sappy targets through `OnInsertListeners` so it exercises the intended Sappy path. Scenarios: @@ -31,5 +31,6 @@ Scenarios: - Many subscriptions, many updates. - Many subscriptions, some updates, many unsubscriptions. - Many subscriptions, some updates, many unsubscriptions, many resubscriptions, some updates. +- Many subscriptions, many updates, many unsubscriptions, many resubscriptions, many updates. The update timings and allocation counts include reducer calls, server round trips, client frame ticks, row decoding, and listener dispatch. Allocation counts use `GC.GetTotalAllocatedBytes(precise: true)`, so they include allocations from background client work as well as main-thread dispatch. Compare native and custom runs on the same machine/server rather than treating the values as isolated in-process dispatch costs. diff --git a/sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs b/sdks/csharp/tests~/event-handling-benchmarks/client/Program.cs similarity index 100% rename from sdks/csharp/examples~/event-handling-benchmarks/client/Program.cs rename to sdks/csharp/tests~/event-handling-benchmarks/client/Program.cs diff --git a/sdks/csharp/examples~/event-handling-benchmarks/client/client.csproj b/sdks/csharp/tests~/event-handling-benchmarks/client/client.csproj similarity index 66% rename from sdks/csharp/examples~/event-handling-benchmarks/client/client.csproj rename to sdks/csharp/tests~/event-handling-benchmarks/client/client.csproj index 8fee3d03556..a6ea7569fa1 100644 --- a/sdks/csharp/examples~/event-handling-benchmarks/client/client.csproj +++ b/sdks/csharp/tests~/event-handling-benchmarks/client/client.csproj @@ -16,8 +16,8 @@ - - + +