11namespace PowerSync . Common . Client ;
22
3+ using System . Diagnostics ;
34using System . Runtime . CompilerServices ;
45using System . Text . RegularExpressions ;
56using System . Threading . Tasks ;
@@ -131,7 +132,7 @@ public class PowerSyncDatabase : IPowerSyncDatabase
131132 public IDBAdapter Database { get ; protected set ; }
132133 private CompiledSchema schema ;
133134
134- private static readonly int DEFAULT_WATCH_THROTTLE_MS = 30 ;
135+ private const int DEFAULT_WATCH_THROTTLE_MS = 30 ;
135136 private static readonly Regex POWERSYNC_TABLE_MATCH = new Regex ( @"(^ps_data__|^ps_data_local__)" , RegexOptions . Compiled ) ;
136137
137138 public bool Closed { get ; protected set ; }
@@ -785,19 +786,21 @@ public IAsyncEnumerable<WatchOnChangeEvent> OnChange(SQLWatchOptions? options =
785786
786787 // Return the actual IAsyncEnumerable here, using OnChange as a synchronous wrapper that blocks until the
787788 // connection is established
788- return OnChangeCore ( powersyncTables , listener , signal , options ? . TriggerImmediately == true ) ;
789+ var throttleMs = options ? . ThrottleMs ?? DEFAULT_WATCH_THROTTLE_MS ;
790+ return OnChangeCore ( powersyncTables , listener , signal , options ? . TriggerImmediately == true , throttleMs ) ;
789791 }
790792
791793 private async IAsyncEnumerable < WatchOnChangeEvent > OnChangeCore (
792794 HashSet < string > watchedTables ,
793795 IAsyncEnumerable < DBAdapterEvents . TablesUpdatedEvent > listener ,
794796 CancellationTokenSource signal ,
795- bool triggerImmediately
797+ bool triggerImmediately ,
798+ int throttleMs = DEFAULT_WATCH_THROTTLE_MS
796799 )
797800 {
798801 try
799802 {
800- await foreach ( var update in OnRawTableChange ( watchedTables , listener , signal . Token , triggerImmediately ) )
803+ await foreach ( var update in OnRawTableChange ( watchedTables , listener , signal . Token , triggerImmediately , throttleMs ) )
801804 {
802805 // Convert from 'ps_data__<name>' to '<name>'
803806 for ( int i = 0 ; i < update . ChangedTables . Length ; i ++ )
@@ -875,6 +878,7 @@ private async IAsyncEnumerable<T[]> WatchCore<T>(
875878 bool isRestart = false ;
876879 var currentRestartCts = initialRestartCts ;
877880 var currentListener = initialListener ;
881+ var throttleMs = options ? . ThrottleMs ?? DEFAULT_WATCH_THROTTLE_MS ;
878882
879883 try
880884 {
@@ -898,7 +902,8 @@ private async IAsyncEnumerable<T[]> WatchCore<T>(
898902 powersyncTables ,
899903 currentListener ,
900904 currentRestartCts . Token ,
901- isRestart || ( options ? . TriggerImmediately == true )
905+ isRestart || ( options ? . TriggerImmediately == true ) ,
906+ throttleMs
902907 ) . GetAsyncEnumerator ( ) ;
903908
904909 // Continually wait for either OnChange or SchemaChanged to fire
@@ -986,26 +991,111 @@ private async IAsyncEnumerable<WatchOnChangeEvent> OnRawTableChange(
986991 HashSet < string > watchedTables ,
987992 IAsyncEnumerable < DBAdapterEvents . TablesUpdatedEvent > listener ,
988993 [ EnumeratorCancellation ] CancellationToken token ,
989- bool triggerImmediately = false
994+ bool triggerImmediately = false ,
995+ int throttleMs = DEFAULT_WATCH_THROTTLE_MS
990996 )
991997 {
992998 if ( triggerImmediately )
993999 {
9941000 yield return new WatchOnChangeEvent { ChangedTables = [ ] } ;
9951001 }
9961002
997- HashSet < string > changedTables = new ( ) ;
998- await foreach ( var e in listener )
1003+ if ( throttleMs <= 0 )
9991004 {
1000- // Extract the changed tables and intersect with the watched tables
1001- changedTables . Clear ( ) ;
1002- GetTablesFromNotification ( e . TablesUpdated , changedTables ) ;
1003- changedTables . IntersectWith ( watchedTables ) ;
1005+ // Fast path: no throttling
1006+ HashSet < string > changedTables = new ( ) ;
1007+ await foreach ( var e in listener )
1008+ {
1009+ changedTables . Clear ( ) ;
1010+ GetTablesFromNotification ( e . TablesUpdated , changedTables ) ;
1011+ changedTables . IntersectWith ( watchedTables ) ;
1012+ if ( changedTables . Count == 0 ) continue ;
1013+ yield return new WatchOnChangeEvent { ChangedTables = [ .. changedTables ] } ;
1014+ }
1015+ yield break ;
1016+ }
1017+
1018+ // Throttled path: Task.WhenAny loop with leading-edge emit
1019+ var listenerEnumerator = listener . GetAsyncEnumerator ( token ) ;
1020+ try
1021+ {
1022+ var accumulated = new HashSet < string > ( ) ;
1023+ long lastYieldTime = 0 ;
1024+ Task < bool > moveNextTask = listenerEnumerator . MoveNextAsync ( ) . AsTask ( ) ;
1025+ Task ? throttleTask = null ;
1026+
1027+ while ( true )
1028+ {
1029+ if ( throttleTask != null )
1030+ await Task . WhenAny ( moveNextTask , throttleTask ) ;
1031+ else
1032+ {
1033+ try { await moveNextTask ; }
1034+ catch ( OperationCanceledException ) { break ; }
1035+ }
10041036
1005- if ( changedTables . Count == 0 ) continue ;
1037+ // Throttle timer fired without a new event
1038+ if ( throttleTask != null && throttleTask . IsCompleted && ! moveNextTask . IsCompleted )
1039+ {
1040+ if ( accumulated . Count > 0 )
1041+ {
1042+ lastYieldTime = Stopwatch . GetTimestamp ( ) ;
1043+ yield return new WatchOnChangeEvent { ChangedTables = [ .. accumulated ] } ;
1044+ accumulated . Clear ( ) ;
1045+ }
1046+ throttleTask = null ;
1047+ continue ;
1048+ }
1049+
1050+ // A new event arrived (possibly alongside throttle)
1051+ bool hasNext ;
1052+ try { hasNext = await moveNextTask ; }
1053+ catch ( OperationCanceledException ) { break ; }
1054+ if ( ! hasNext ) break ;
10061055
1007- yield return new WatchOnChangeEvent { ChangedTables = [ .. changedTables ] } ;
1056+ AccumulateMatchingTables ( listenerEnumerator . Current , watchedTables , accumulated ) ;
1057+
1058+ if ( accumulated . Count > 0 )
1059+ {
1060+ var now = Stopwatch . GetTimestamp ( ) ;
1061+ var elapsedMs = ( now - lastYieldTime ) * 1000.0 / Stopwatch . Frequency ;
1062+
1063+ if ( elapsedMs >= throttleMs )
1064+ {
1065+ // Leading edge: emit immediately
1066+ lastYieldTime = now ;
1067+ yield return new WatchOnChangeEvent { ChangedTables = [ .. accumulated ] } ;
1068+ accumulated . Clear ( ) ;
1069+ throttleTask = null ;
1070+ }
1071+ else
1072+ {
1073+ throttleTask ??= Task . Delay ( ( int ) ( throttleMs - elapsedMs ) , token ) ;
1074+ }
1075+ }
1076+
1077+ moveNextTask = listenerEnumerator . MoveNextAsync ( ) . AsTask ( ) ;
1078+ }
1079+
1080+ if ( accumulated . Count > 0 )
1081+ yield return new WatchOnChangeEvent { ChangedTables = [ .. accumulated ] } ;
10081082 }
1083+ finally
1084+ {
1085+ await listenerEnumerator . DisposeAsync ( ) ;
1086+ }
1087+ }
1088+
1089+ private static void AccumulateMatchingTables (
1090+ DBAdapterEvents . TablesUpdatedEvent e ,
1091+ HashSet < string > watchedTables ,
1092+ HashSet < string > accumulated
1093+ )
1094+ {
1095+ var tables = new HashSet < string > ( ) ;
1096+ GetTablesFromNotification ( e . TablesUpdated , tables ) ;
1097+ tables . IntersectWith ( watchedTables ) ;
1098+ accumulated . UnionWith ( tables ) ;
10091099 }
10101100
10111101 private static void GetTablesFromNotification ( INotification updateNotification , HashSet < string > changedTables )
0 commit comments