/ src / modules / cmdpal / Microsoft.CmdPal.UI / Helpers / AdaptiveCache`2.cs
AdaptiveCache`2.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 System.Diagnostics.CodeAnalysis;
  7  using System.Runtime.CompilerServices;
  8  using Microsoft.CmdPal.Core.Common.Helpers;
  9  
 10  namespace Microsoft.CmdPal.UI.Helpers;
 11  
 12  /// <summary>
 13  /// A high-performance, near-lock-free adaptive cache optimized for UI Icons.
 14  /// Eviction merely drops references to allow the GC to manage UI-bound lifetimes.
 15  /// </summary>
 16  internal sealed class AdaptiveCache<TKey, TValue>
 17      where TKey : IEquatable<TKey>
 18  {
 19      private readonly int _capacity;
 20      private readonly double _decayFactor;
 21      private readonly TimeSpan _decayInterval;
 22  
 23      private readonly ConcurrentDictionary<TKey, CacheEntry> _map;
 24      private readonly ConcurrentStack<CacheEntry> _pool = [];
 25      private readonly WaitCallback _maintenanceCallback;
 26  
 27      private long _currentTick;
 28      private long _lastDecayTicks = DateTime.UtcNow.Ticks;
 29      private InterlockedBoolean _maintenanceSwitch = new(false);
 30  
 31      public AdaptiveCache(int capacity = 384, TimeSpan? decayInterval = null, double decayFactor = 0.5)
 32      {
 33          _capacity = capacity;
 34          _decayInterval = decayInterval ?? TimeSpan.FromMinutes(5);
 35          _decayFactor = decayFactor;
 36          _map = new ConcurrentDictionary<TKey, CacheEntry>(Environment.ProcessorCount, capacity);
 37  
 38          _maintenanceCallback = static state =>
 39          {
 40              var cache = (AdaptiveCache<TKey, TValue>)state!;
 41              try
 42              {
 43                  cache.PerformCleanup();
 44              }
 45              finally
 46              {
 47                  cache._maintenanceSwitch.Clear();
 48              }
 49          };
 50      }
 51  
 52      public TValue GetOrAdd<TArg>(TKey key, Func<TKey, TArg, TValue> factory, TArg arg)
 53      {
 54          if (_map.TryGetValue(key, out var entry))
 55          {
 56              entry.Update(Interlocked.Increment(ref _currentTick));
 57              return entry.Value!;
 58          }
 59  
 60          if (!_pool.TryPop(out var newEntry))
 61          {
 62              newEntry = new CacheEntry();
 63          }
 64  
 65          var value = factory(key, arg);
 66          var tick = Interlocked.Increment(ref _currentTick);
 67          newEntry.Initialize(key, value, 1.0, tick);
 68  
 69          if (!_map.TryAdd(key, newEntry))
 70          {
 71              newEntry.Clear();
 72              _pool.Push(newEntry);
 73  
 74              if (_map.TryGetValue(key, out var existing))
 75              {
 76                  existing.Update(tick);
 77                  return existing.Value!;
 78              }
 79          }
 80  
 81          if (ShouldMaintenanceRun())
 82          {
 83              TryRunMaintenance();
 84          }
 85  
 86          return value;
 87      }
 88  
 89      public bool TryGet(TKey key, [MaybeNullWhen(false)] out TValue value)
 90      {
 91          if (_map.TryGetValue(key, out var entry))
 92          {
 93              entry.Update(Interlocked.Increment(ref _currentTick));
 94              value = entry.Value;
 95              return true;
 96          }
 97  
 98          value = default;
 99          return false;
100      }
101  
102      public void Add(TKey key, TValue value)
103      {
104          var tick = Interlocked.Increment(ref _currentTick);
105  
106          if (_map.TryGetValue(key, out var existing))
107          {
108              existing.Update(tick);
109              existing.SetValue(value);
110              return;
111          }
112  
113          if (!_pool.TryPop(out var newEntry))
114          {
115              newEntry = new CacheEntry();
116          }
117  
118          newEntry.Initialize(key, value, 1.0, tick);
119  
120          if (!_map.TryAdd(key, newEntry))
121          {
122              newEntry.Clear();
123              _pool.Push(newEntry);
124          }
125  
126          if (ShouldMaintenanceRun())
127          {
128              TryRunMaintenance();
129          }
130      }
131  
132      public bool TryRemove(TKey key)
133      {
134          if (_map.TryRemove(key, out var evicted))
135          {
136              evicted.Clear();
137              _pool.Push(evicted);
138              return true;
139          }
140  
141          return false;
142      }
143  
144      public void Clear()
145      {
146          foreach (var key in _map.Keys)
147          {
148              TryRemove(key);
149          }
150  
151          Interlocked.Exchange(ref _currentTick, 0);
152      }
153  
154      private bool ShouldMaintenanceRun()
155      {
156          return _map.Count > _capacity || (DateTime.UtcNow.Ticks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks;
157      }
158  
159      private void TryRunMaintenance()
160      {
161          if (_maintenanceSwitch.Set())
162          {
163              ThreadPool.UnsafeQueueUserWorkItem(_maintenanceCallback, this);
164          }
165      }
166  
167      private void PerformCleanup()
168      {
169          var nowTicks = DateTime.UtcNow.Ticks;
170          var isDecay = (nowTicks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks;
171          if (isDecay)
172          {
173              Interlocked.Exchange(ref _lastDecayTicks, nowTicks);
174          }
175  
176          var currentTick = Interlocked.Read(ref _currentTick);
177  
178          foreach (var (key, entry) in _map)
179          {
180              if (isDecay)
181              {
182                  entry.Decay(_decayFactor);
183              }
184  
185              var score = CalculateScore(entry, currentTick);
186  
187              if (score < 0.1 || _map.Count > _capacity)
188              {
189                  if (_map.TryRemove(key, out var evicted))
190                  {
191                      evicted.Clear();
192                      _pool.Push(evicted);
193                  }
194              }
195          }
196      }
197  
198      /// <summary>
199      /// Calculates the survival score of an entry.
200      /// Higher score = stay in cache; Lower score = priority for eviction.
201      /// </summary>
202      [MethodImpl(MethodImplOptions.AggressiveInlining)]
203      private static double CalculateScore(CacheEntry entry, long currentTick)
204      {
205          // Tuning parameter: How much weight to give recency vs frequency.
206          // - a larger ageWeight makes the cache behave more like LRU (Least Recently Used).
207          // - a smaller ageWeight makes it behave more like LFU (Least Frequently Used).
208          const double ageWeight = 0.001;
209  
210          var frequency = entry.GetFrequency();
211          var age = currentTick - entry.GetLastAccess();
212  
213          return frequency - (age * ageWeight);
214      }
215  
216      /// <summary>
217      /// Represents a single pooled entry in the cache, containing the value and
218      /// atomic metadata for adaptive eviction logic.
219      /// </summary>
220      private sealed class CacheEntry
221      {
222          /// <summary>
223          /// Gets the key associated with this entry. Used primarily for identification during cleanup.
224          /// </summary>
225          public TKey Key { get; private set; } = default!;
226  
227          /// <summary>
228          /// Gets the cached value. This reference is cleared on eviction to allow GC collection.
229          /// </summary>
230          public TValue Value { get; private set; } = default!;
231  
232          /// <summary>
233          /// Stores the frequency count as double bits to allow for Interlocked atomic math.
234          /// Frequencies are decayed over time to ensure the cache adapts to new usage patterns.
235          /// </summary>
236          /// <remarks>
237          /// This allows the use of Interlocked.CompareExchange to perform thread-safe floating point
238          /// arithmetic without a global lock.
239          /// </remarks>
240          private long _frequencyBits;
241  
242          /// <summary>
243          /// The tick (monotonically increasing counter) of the last time this entry was accessed.
244          /// </summary>
245          private long _lastAccessTick;
246  
247          public void Initialize(TKey key, TValue value, double frequency, long lastAccessTick)
248          {
249              Key = key;
250              Value = value;
251              _frequencyBits = BitConverter.DoubleToInt64Bits(frequency);
252              _lastAccessTick = lastAccessTick;
253          }
254  
255          public void SetValue(TValue value)
256          {
257              Value = value;
258          }
259  
260          public void Clear()
261          {
262              Key = default!;
263              Value = default!;
264          }
265  
266          public void Update(long tick)
267          {
268              Interlocked.Exchange(ref _lastAccessTick, tick);
269              long initial, updated;
270              do
271              {
272                  initial = Interlocked.Read(ref _frequencyBits);
273                  updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) + 1.0);
274              }
275              while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial);
276          }
277  
278          public void Decay(double factor)
279          {
280              long initial, updated;
281              do
282              {
283                  initial = Interlocked.Read(ref _frequencyBits);
284                  updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) * factor);
285              }
286              while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial);
287          }
288  
289          public double GetFrequency()
290          {
291              return BitConverter.Int64BitsToDouble(Interlocked.Read(ref _frequencyBits));
292          }
293  
294          public long GetLastAccess()
295          {
296              return Interlocked.Read(ref _lastAccessTick);
297          }
298      }
299  }