BatchUpdateManager.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System.Collections.Concurrent; 6 using Microsoft.CmdPal.Core.Common; 7 using Microsoft.CmdPal.Core.Common.Helpers; 8 9 namespace Microsoft.CmdPal.Core.ViewModels; 10 11 internal static class BatchUpdateManager 12 { 13 private const int ExpectedBatchSize = 32; 14 15 // 30 ms chosen empirically to balance responsiveness and batching: 16 // - Keeps perceived latency low (< ~50 ms) for user-visible updates. 17 // - Still allows multiple COM/background events to be coalesced into a single batch. 18 private static readonly TimeSpan BatchDelay = TimeSpan.FromMilliseconds(30); 19 private static readonly ConcurrentQueue<IBatchUpdateTarget> DirtyQueue = []; 20 private static readonly Timer Timer = new(static _ => Flush(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); 21 22 private static InterlockedBoolean _isFlushScheduled; 23 24 /// <summary> 25 /// Enqueue a target for batched processing. Safe to call from any thread (including COM callbacks). 26 /// </summary> 27 public static void Queue(IBatchUpdateTarget target) 28 { 29 if (!target.TryMarkBatchQueued()) 30 { 31 return; // already queued in current batch window 32 } 33 34 DirtyQueue.Enqueue(target); 35 TryScheduleFlush(); 36 } 37 38 private static void TryScheduleFlush() 39 { 40 if (!_isFlushScheduled.Set()) 41 { 42 return; 43 } 44 45 if (DirtyQueue.IsEmpty) 46 { 47 _isFlushScheduled.Clear(); 48 49 if (DirtyQueue.IsEmpty) 50 { 51 return; 52 } 53 54 if (!_isFlushScheduled.Set()) 55 { 56 return; 57 } 58 } 59 60 try 61 { 62 Timer.Change(BatchDelay, Timeout.InfiniteTimeSpan); 63 } 64 catch (Exception ex) 65 { 66 _isFlushScheduled.Clear(); 67 CoreLogger.LogError("Failed to arm batch timer.", ex); 68 } 69 } 70 71 private static void Flush() 72 { 73 try 74 { 75 var drained = new List<IBatchUpdateTarget>(ExpectedBatchSize); 76 while (DirtyQueue.TryDequeue(out var item)) 77 { 78 drained.Add(item); 79 } 80 81 if (drained.Count == 0) 82 { 83 return; 84 } 85 86 // LOAD BEARING: 87 // ApplyPendingUpdates must run on a background thread. 88 // The VM itself is responsible for marshaling UI notifications to its _uiScheduler. 89 ApplyBatch(drained); 90 } 91 catch (Exception ex) 92 { 93 // Don't kill the timer thread. 94 CoreLogger.LogError("Batch flush failed.", ex); 95 } 96 finally 97 { 98 _isFlushScheduled.Clear(); 99 TryScheduleFlush(); 100 } 101 } 102 103 private static void ApplyBatch(List<IBatchUpdateTarget> items) 104 { 105 // Runs on the Timer callback thread (ThreadPool). That's fine: background work only. 106 foreach (var item in items) 107 { 108 // Allow re-queueing immediately if more COM events arrive during apply. 109 item.ClearBatchQueued(); 110 111 try 112 { 113 item.ApplyPendingUpdates(); 114 } 115 catch (Exception ex) 116 { 117 CoreLogger.LogError("Failed to apply pending updates for a batched target.", ex); 118 } 119 } 120 } 121 } 122 123 internal interface IBatchUpdateTarget 124 { 125 /// <summary>UI scheduler (used by targets internally for UI marshaling). Kept here for diagnostics / consistency.</summary> 126 TaskScheduler UIScheduler { get; } 127 128 /// <summary>Apply any coalesced updates. Must be safe to call on a background thread.</summary> 129 void ApplyPendingUpdates(); 130 131 /// <summary>De-dupe gate: returns true only for the first enqueue until cleared.</summary> 132 bool TryMarkBatchQueued(); 133 134 /// <summary>Clear the de-dupe gate so the item can be queued again.</summary> 135 void ClearBatchQueued(); 136 }