From 8d16648e7e5f2d94e574b3dcb5ec84fa32ffeb02 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Mon, 19 May 2025 14:37:20 -0400 Subject: [PATCH 1/8] Pre process messages in parallel, take 1 --- sdks/csharp/src/SpacetimeDBClient.cs | 169 +++++++++++++++++---------- 1 file changed, 108 insertions(+), 61 deletions(-) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index b0303b2460a..92c3d58691b 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -387,6 +387,29 @@ private static (BinaryReader reader, int rowCount) ParseRowList(BsatnRowList lis } ); + /// + /// A collection of updates to the same table that need to be pre-processed. + /// This is the unit of work that is spread across worker threads. + /// + internal struct UpdatesToPreProcess + { + /// + /// The table handle to use to parse rows. + /// You should only use this in a thread-safe way. + /// + public IRemoteTableHandle Table; + + /// + /// The updates to parse. + /// + public List Updates; + + /// + /// The delta to fill with data. Starts out empty. + /// + public MultiDictionaryDelta Delta; + } + #if UNITY_WEBGL && !UNITY_EDITOR IEnumerator PreProcessMessages() #else @@ -416,8 +439,9 @@ void PreProcessMessages() } } - IEnumerable<(IRemoteTableHandle, TableUpdate)> GetTables(DatabaseUpdate updates) + IEnumerable GetUpdatesToPreProcess(DatabaseUpdate updates, ProcessedDatabaseUpdate dbOps) { + Dictionary tableToUpdates = new(32); foreach (var update in updates.Tables) { var tableName = update.TableName; @@ -427,21 +451,33 @@ void PreProcessMessages() Log.Error($"Unknown table name: {tableName}"); continue; } - yield return (table, update); + if (tableToUpdates.ContainsKey(table)) + { + tableToUpdates[table].Updates.Add(update); + } + else + { + var delta = dbOps.DeltaForTable(table); + tableToUpdates[table] = new() + { + Table = table, + Updates = new() { update }, + Delta = delta + }; + } } + + return tableToUpdates.Values; } ProcessedDatabaseUpdate PreProcessLegacySubscription(InitialSubscription initSub) { var dbOps = ProcessedDatabaseUpdate.New(); - // This is all of the inserts - int cap = initSub.DatabaseUpdate.Tables.Sum(a => (int)a.NumRows); - // First apply all of the state - foreach (var (table, update) in GetTables(initSub.DatabaseUpdate)) + Parallel.ForEach(GetUpdatesToPreProcess(initSub.DatabaseUpdate, dbOps), (todo) => { - PreProcessInsertOnlyTable(table, update, dbOps); - } + PreProcessInsertOnlyTable(todo); + }); return dbOps; } @@ -452,88 +488,92 @@ ProcessedDatabaseUpdate PreProcessLegacySubscription(InitialSubscription initSub ProcessedDatabaseUpdate PreProcessSubscribeMultiApplied(SubscribeMultiApplied subscribeMultiApplied) { var dbOps = ProcessedDatabaseUpdate.New(); - foreach (var (table, update) in GetTables(subscribeMultiApplied.Update)) + Parallel.ForEach(GetUpdatesToPreProcess(subscribeMultiApplied.Update, dbOps), (todo) => { - PreProcessInsertOnlyTable(table, update, dbOps); - } + PreProcessInsertOnlyTable(todo); + }); return dbOps; } - void PreProcessInsertOnlyTable(IRemoteTableHandle table, TableUpdate update, ProcessedDatabaseUpdate dbOps) + void PreProcessInsertOnlyTable(UpdatesToPreProcess todo) { - var delta = dbOps.DeltaForTable(table); - - foreach (var cqu in update.Updates) + foreach (var update in todo.Updates) { - var qu = DecompressDecodeQueryUpdate(cqu); - if (qu.Deletes.RowsData.Count > 0) - { - Log.Warn("Non-insert during an insert-only server message!"); - } - var (insertReader, insertRowCount) = ParseRowList(qu.Inserts); - for (var i = 0; i < insertRowCount; i++) + foreach (var cqu in update.Updates) { - var obj = Decode(table, insertReader, out var pk); - delta.Add(pk, obj); + var qu = DecompressDecodeQueryUpdate(cqu); + if (qu.Deletes.RowsData.Count > 0) + { + Log.Warn("Non-insert during an insert-only server message!"); + } + var (insertReader, insertRowCount) = ParseRowList(qu.Inserts); + for (var i = 0; i < insertRowCount; i++) + { + var obj = Decode(todo.Table, insertReader, out var pk); + todo.Delta.Add(pk, obj); + } } } } - void PreProcessDeleteOnlyTable(IRemoteTableHandle table, TableUpdate update, ProcessedDatabaseUpdate dbOps) + void PreProcessDeleteOnlyTable(UpdatesToPreProcess todo) { - var delta = dbOps.DeltaForTable(table); - foreach (var cqu in update.Updates) + foreach (var update in todo.Updates) { - var qu = DecompressDecodeQueryUpdate(cqu); - if (qu.Inserts.RowsData.Count > 0) + foreach (var cqu in update.Updates) { - Log.Warn("Non-delete during a delete-only operation!"); - } + var qu = DecompressDecodeQueryUpdate(cqu); + if (qu.Inserts.RowsData.Count > 0) + { + Log.Warn("Non-delete during a delete-only operation!"); + } - var (deleteReader, deleteRowCount) = ParseRowList(qu.Deletes); - for (var i = 0; i < deleteRowCount; i++) - { - var obj = Decode(table, deleteReader, out var pk); - delta.Remove(pk, obj); + var (deleteReader, deleteRowCount) = ParseRowList(qu.Deletes); + for (var i = 0; i < deleteRowCount; i++) + { + var obj = Decode(todo.Table, deleteReader, out var pk); + todo.Delta.Remove(pk, obj); + } } } } - void PreProcessTable(IRemoteTableHandle table, TableUpdate update, ProcessedDatabaseUpdate dbOps) + void PreProcessTable(UpdatesToPreProcess todo) { - var delta = dbOps.DeltaForTable(table); - foreach (var cqu in update.Updates) + foreach (var update in todo.Updates) { - var qu = DecompressDecodeQueryUpdate(cqu); + foreach (var compressableQueryUpdate in update.Updates) + { + var qu = DecompressDecodeQueryUpdate(compressableQueryUpdate); - // Because we are accumulating into a MultiDictionaryDelta that will be applied all-at-once - // to the table, it doesn't matter that we call Add before Remove here. + // Because we are accumulating into a MultiDictionaryDelta that will be applied all-at-once + // to the table, it doesn't matter that we call Add before Remove here. - var (insertReader, insertRowCount) = ParseRowList(qu.Inserts); - for (var i = 0; i < insertRowCount; i++) - { - var obj = Decode(table, insertReader, out var pk); - delta.Add(pk, obj); - } + var (insertReader, insertRowCount) = ParseRowList(qu.Inserts); + for (var i = 0; i < insertRowCount; i++) + { + var obj = Decode(todo.Table, insertReader, out var pk); + todo.Delta.Add(pk, obj); + } - var (deleteReader, deleteRowCount) = ParseRowList(qu.Deletes); - for (var i = 0; i < deleteRowCount; i++) - { - var obj = Decode(table, deleteReader, out var pk); - delta.Remove(pk, obj); + var (deleteReader, deleteRowCount) = ParseRowList(qu.Deletes); + for (var i = 0; i < deleteRowCount; i++) + { + var obj = Decode(todo.Table, deleteReader, out var pk); + todo.Delta.Remove(pk, obj); + } } } - } ProcessedDatabaseUpdate PreProcessUnsubscribeMultiApplied(UnsubscribeMultiApplied unsubMultiApplied) { var dbOps = ProcessedDatabaseUpdate.New(); - foreach (var (table, update) in GetTables(unsubMultiApplied.Update)) + Parallel.ForEach(GetUpdatesToPreProcess(unsubMultiApplied.Update, dbOps), (todo) => { - PreProcessDeleteOnlyTable(table, update, dbOps); - } + PreProcessDeleteOnlyTable(todo); + }); return dbOps; } @@ -542,10 +582,10 @@ ProcessedDatabaseUpdate PreProcessDatabaseUpdate(DatabaseUpdate updates) { var dbOps = ProcessedDatabaseUpdate.New(); - foreach (var (table, update) in GetTables(updates)) + Parallel.ForEach(GetUpdatesToPreProcess(updates, dbOps), (todo) => { - PreProcessTable(table, update, dbOps); - } + PreProcessTable(todo); + }); return dbOps; } @@ -904,7 +944,14 @@ private void OnMessageProcessComplete(ProcessedMessage processed) } } - // Note: this method is called from unit tests. + /// + /// Callback for receiving a message from the websocket. + /// Note: this method is invoked on the websocket thread, not on the main thread. + /// That's fine, since all it does is push a message to a queue. + /// Note: this method is called from unit tests. + /// + /// + /// internal void OnMessageReceived(byte[] bytes, DateTime timestamp) => _messageQueue.Add(new UnprocessedMessage { bytes = bytes, timestamp = timestamp }); From 8c18ee0c838dd1b91d300e2a134ccdb062c224b3 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Mon, 19 May 2025 14:59:25 -0400 Subject: [PATCH 2/8] Add some comments --- sdks/csharp/src/SpacetimeDBClient.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index 92c3d58691b..217337c7e6f 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -401,11 +401,16 @@ internal struct UpdatesToPreProcess /// /// The updates to parse. + /// There may be multiple TableUpdates corresponding to a single table. + /// (Each TableUpdate then contains multiple CompressableQueryUpdates. + /// Unfortunately, these are compressed independently due to serverside limitations.) /// public List Updates; /// /// The delta to fill with data. Starts out empty. + /// This delta is also stored in a ProcessedDatabaseUpdate, so modifying it + /// directly modifies a ProcessedDatabaseUpdate that needs to be filled in with data. /// public MultiDictionaryDelta Delta; } @@ -441,7 +446,9 @@ void PreProcessMessages() IEnumerable GetUpdatesToPreProcess(DatabaseUpdate updates, ProcessedDatabaseUpdate dbOps) { - Dictionary tableToUpdates = new(32); + // There may be multiple TableUpdates corresponding to a single table in a DatabaseUpdate. + // Preemptively group them. + Dictionary tableToUpdates = new(updates.Tables.Count); foreach (var update in updates.Tables) { var tableName = update.TableName; From abd674d31d4242bc0e6f63c814759b4ca42aaafa Mon Sep 17 00:00:00 2001 From: James Gilles Date: Mon, 19 May 2025 15:10:45 -0400 Subject: [PATCH 3/8] More commenting --- sdks/csharp/src/SpacetimeDBClient.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index 217337c7e6f..b80e61738fc 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -390,6 +390,22 @@ private static (BinaryReader reader, int rowCount) ParseRowList(BsatnRowList lis /// /// A collection of updates to the same table that need to be pre-processed. /// This is the unit of work that is spread across worker threads. + /// + /// It is fairly coarse-grained -- which means latency depends on the table with the most updates. + /// + /// We actually could reduce latency further by doing more fine-grained parallelism -- + /// splitting up row parsing within a TableUpdate using the RowSizeHint data in a BsatnRowList. + /// Probably we would want to split large messages into chunks, but only large messages. + /// However, if we do this, we'd need to have data from multiple threads aggregated into a single + /// MultiDictionaryDelta. To do this, we'd need to: + /// - make MultiDictionaryDelta a synchronized data structure + /// - or, add methods to MultiDictionaryDelta that allow them to be combined after being + /// produced independently. + /// + /// I'm not comfortable doing either of these while MultiDictionaryDelta is as complicated as it is. + /// Commit ba9f3be made MultiDictionary so complicated because we needed to handle a weird edge case. + /// https://github.com/clockworklabs/SpacetimeDB/pull/2654 made the edge-case impossible server-side, + /// so once it has been released for a while, we could consider reverting ba9f3be and doing fancier parallelism here. /// internal struct UpdatesToPreProcess { From a401554079762073c528013ff8f02ed0b8cabc7a Mon Sep 17 00:00:00 2001 From: James Gilles Date: Mon, 19 May 2025 15:27:51 -0400 Subject: [PATCH 4/8] Don't preprocess tiny messages in parallel --- sdks/csharp/src/SpacetimeDBClient.cs | 64 ++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index b80e61738fc..87762a70575 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -431,6 +431,35 @@ internal struct UpdatesToPreProcess public MultiDictionaryDelta Delta; } + /// + /// *Maybe* do something in parallel, depending on how many bytes we need to process. + /// + /// + /// + /// + /// + /// + static void MaybeParallelForEach( + IEnumerable values, + Action action, + int bytes, + int enoughBytesToParallelize = 64_000 + ) + { + if (bytes >= enoughBytesToParallelize) + { + Parallel.ForEach(values, action); + } + else + { + foreach (var value in values) + { + action(value); + } + } + + } + #if UNITY_WEBGL && !UNITY_EDITOR IEnumerator PreProcessMessages() #else @@ -493,14 +522,15 @@ IEnumerable GetUpdatesToPreProcess(DatabaseUpdate updates, return tableToUpdates.Values; } - ProcessedDatabaseUpdate PreProcessLegacySubscription(InitialSubscription initSub) + + ProcessedDatabaseUpdate PreProcessLegacySubscription(InitialSubscription initSub, int messageBytes) { var dbOps = ProcessedDatabaseUpdate.New(); - Parallel.ForEach(GetUpdatesToPreProcess(initSub.DatabaseUpdate, dbOps), (todo) => + MaybeParallelForEach(GetUpdatesToPreProcess(initSub.DatabaseUpdate, dbOps), (todo) => { PreProcessInsertOnlyTable(todo); - }); + }, bytes: messageBytes); return dbOps; } @@ -508,13 +538,13 @@ ProcessedDatabaseUpdate PreProcessLegacySubscription(InitialSubscription initSub /// TODO: the dictionary is here for backwards compatibility and can be removed /// once we get rid of legacy subscriptions. /// - ProcessedDatabaseUpdate PreProcessSubscribeMultiApplied(SubscribeMultiApplied subscribeMultiApplied) + ProcessedDatabaseUpdate PreProcessSubscribeMultiApplied(SubscribeMultiApplied subscribeMultiApplied, int messageBytes) { var dbOps = ProcessedDatabaseUpdate.New(); - Parallel.ForEach(GetUpdatesToPreProcess(subscribeMultiApplied.Update, dbOps), (todo) => + MaybeParallelForEach(GetUpdatesToPreProcess(subscribeMultiApplied.Update, dbOps), (todo) => { PreProcessInsertOnlyTable(todo); - }); + }, bytes: messageBytes); return dbOps; } @@ -589,26 +619,26 @@ void PreProcessTable(UpdatesToPreProcess todo) } } - ProcessedDatabaseUpdate PreProcessUnsubscribeMultiApplied(UnsubscribeMultiApplied unsubMultiApplied) + ProcessedDatabaseUpdate PreProcessUnsubscribeMultiApplied(UnsubscribeMultiApplied unsubMultiApplied, int messageBytes) { var dbOps = ProcessedDatabaseUpdate.New(); - Parallel.ForEach(GetUpdatesToPreProcess(unsubMultiApplied.Update, dbOps), (todo) => + MaybeParallelForEach(GetUpdatesToPreProcess(unsubMultiApplied.Update, dbOps), (todo) => { PreProcessDeleteOnlyTable(todo); - }); + }, bytes: messageBytes); return dbOps; } - ProcessedDatabaseUpdate PreProcessDatabaseUpdate(DatabaseUpdate updates) + ProcessedDatabaseUpdate PreProcessDatabaseUpdate(DatabaseUpdate updates, int messageBytes) { var dbOps = ProcessedDatabaseUpdate.New(); - Parallel.ForEach(GetUpdatesToPreProcess(updates, dbOps), (todo) => + MaybeParallelForEach(GetUpdatesToPreProcess(updates, dbOps), (todo) => { PreProcessTable(todo); - }); + }, bytes: messageBytes); return dbOps; } @@ -637,12 +667,12 @@ ProcessedMessage PreProcessMessage(UnprocessedMessage unprocessed) switch (message) { case ServerMessage.InitialSubscription(var initSub): - dbOps = PreProcessLegacySubscription(initSub); + dbOps = PreProcessLegacySubscription(initSub, unprocessed.bytes.Length); break; case ServerMessage.SubscribeApplied(var subscribeApplied): break; case ServerMessage.SubscribeMultiApplied(var subscribeMultiApplied): - dbOps = PreProcessSubscribeMultiApplied(subscribeMultiApplied); + dbOps = PreProcessSubscribeMultiApplied(subscribeMultiApplied, unprocessed.bytes.Length); break; case ServerMessage.SubscriptionError(var subscriptionError): // do nothing; main thread will warn. @@ -651,7 +681,7 @@ ProcessedMessage PreProcessMessage(UnprocessedMessage unprocessed) // do nothing; main thread will warn. break; case ServerMessage.UnsubscribeMultiApplied(var unsubscribeMultiApplied): - dbOps = PreProcessUnsubscribeMultiApplied(unsubscribeMultiApplied); + dbOps = PreProcessUnsubscribeMultiApplied(unsubscribeMultiApplied, unprocessed.bytes.Length); break; case ServerMessage.TransactionUpdate(var transactionUpdate): // Convert the generic event arguments in to a domain specific event object @@ -680,11 +710,11 @@ ProcessedMessage PreProcessMessage(UnprocessedMessage unprocessed) if (transactionUpdate.Status is UpdateStatus.Committed(var committed)) { - dbOps = PreProcessDatabaseUpdate(committed); + dbOps = PreProcessDatabaseUpdate(committed, unprocessed.bytes.Length); } break; case ServerMessage.TransactionUpdateLight(var update): - dbOps = PreProcessDatabaseUpdate(update.Update); + dbOps = PreProcessDatabaseUpdate(update.Update, unprocessed.bytes.Length); break; case ServerMessage.IdentityToken(var identityToken): break; From 4da13fb01a24e4a29cb024ef7f189d30be5a9e19 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Mon, 19 May 2025 15:37:12 -0400 Subject: [PATCH 5/8] Add comment --- sdks/csharp/src/SpacetimeDBClient.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index 87762a70575..3f39ce8c6cf 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -435,15 +435,15 @@ internal struct UpdatesToPreProcess /// *Maybe* do something in parallel, depending on how many bytes we need to process. /// /// - /// - /// - /// - /// + /// The enumerator to consume. + /// The action to perform for each element of the enumerator. + /// The number of bytes in the compressed message. + /// The threshold for whether to parse updates on multiple threads. static void MaybeParallelForEach( IEnumerable values, Action action, int bytes, - int enoughBytesToParallelize = 64_000 + int enoughBytesToParallelize = 32_000 ) { if (bytes >= enoughBytesToParallelize) From 4be7d3b26afb2f90ff1342229d6ff1148d9b61b0 Mon Sep 17 00:00:00 2001 From: James Gilles Date: Tue, 20 May 2025 13:44:58 -0400 Subject: [PATCH 6/8] Change verbiage --- sdks/csharp/src/SpacetimeDBClient.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index 3f39ce8c6cf..9f17316c3d7 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -433,13 +433,14 @@ internal struct UpdatesToPreProcess /// /// *Maybe* do something in parallel, depending on how many bytes we need to process. + /// For small messages, avoid the overhead from parallelizing. /// /// /// The enumerator to consume. /// The action to perform for each element of the enumerator. /// The number of bytes in the compressed message. /// The threshold for whether to parse updates on multiple threads. - static void MaybeParallelForEach( + static void FastParallelForEach( IEnumerable values, Action action, int bytes, From f5b2794a2d916d074333bc7e159acdc137fbe2df Mon Sep 17 00:00:00 2001 From: James Gilles Date: Tue, 20 May 2025 13:45:50 -0400 Subject: [PATCH 7/8] VSCode refactor didn't work --- sdks/csharp/src/SpacetimeDBClient.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index 9f17316c3d7..3ddaa4cc951 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -528,7 +528,7 @@ ProcessedDatabaseUpdate PreProcessLegacySubscription(InitialSubscription initSub { var dbOps = ProcessedDatabaseUpdate.New(); - MaybeParallelForEach(GetUpdatesToPreProcess(initSub.DatabaseUpdate, dbOps), (todo) => + FastParallelForEach(GetUpdatesToPreProcess(initSub.DatabaseUpdate, dbOps), (todo) => { PreProcessInsertOnlyTable(todo); }, bytes: messageBytes); @@ -542,7 +542,7 @@ ProcessedDatabaseUpdate PreProcessLegacySubscription(InitialSubscription initSub ProcessedDatabaseUpdate PreProcessSubscribeMultiApplied(SubscribeMultiApplied subscribeMultiApplied, int messageBytes) { var dbOps = ProcessedDatabaseUpdate.New(); - MaybeParallelForEach(GetUpdatesToPreProcess(subscribeMultiApplied.Update, dbOps), (todo) => + FastParallelForEach(GetUpdatesToPreProcess(subscribeMultiApplied.Update, dbOps), (todo) => { PreProcessInsertOnlyTable(todo); }, bytes: messageBytes); @@ -624,7 +624,7 @@ ProcessedDatabaseUpdate PreProcessUnsubscribeMultiApplied(UnsubscribeMultiApplie { var dbOps = ProcessedDatabaseUpdate.New(); - MaybeParallelForEach(GetUpdatesToPreProcess(unsubMultiApplied.Update, dbOps), (todo) => + FastParallelForEach(GetUpdatesToPreProcess(unsubMultiApplied.Update, dbOps), (todo) => { PreProcessDeleteOnlyTable(todo); }, bytes: messageBytes); @@ -636,7 +636,7 @@ ProcessedDatabaseUpdate PreProcessDatabaseUpdate(DatabaseUpdate updates, int mes { var dbOps = ProcessedDatabaseUpdate.New(); - MaybeParallelForEach(GetUpdatesToPreProcess(updates, dbOps), (todo) => + FastParallelForEach(GetUpdatesToPreProcess(updates, dbOps), (todo) => { PreProcessTable(todo); }, bytes: messageBytes); From 7f4143a86c67eb863aebf698cf77c854e8fd75b3 Mon Sep 17 00:00:00 2001 From: james gilles Date: Tue, 20 May 2025 15:32:38 -0400 Subject: [PATCH 8/8] Update src/SpacetimeDBClient.cs Co-authored-by: rekhoff --- sdks/csharp/src/SpacetimeDBClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs index 3ddaa4cc951..58c6d90b82c 100644 --- a/sdks/csharp/src/SpacetimeDBClient.cs +++ b/sdks/csharp/src/SpacetimeDBClient.cs @@ -432,7 +432,7 @@ internal struct UpdatesToPreProcess } /// - /// *Maybe* do something in parallel, depending on how many bytes we need to process. + /// An optimized ForEach, depending on how many bytes we need to process. /// For small messages, avoid the overhead from parallelizing. /// ///