File: Utilities\MemoryCache`2.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
 
namespace Microsoft.CodeAnalysis.Razor.Utilities;
 
/// <summary>
///  A thread-safe, size-limited cache with approximate LRU (Least Recently Used)
///  eviction policy. When the cache reaches its size limit, it removes approximately
///  half of the least recently used entries.
/// </summary>
/// <typeparam name="TKey">The type of keys in the cache.</typeparam>
/// <typeparam name="TValue">The type of values in the cache.</typeparam>
/// <param name="sizeLimit">The maximum number of entries the cache can hold before compaction is triggered.</param>
/// <param name="concurrencyLevel">The estimated number of threads that will update the cache concurrently.</param>
internal sealed partial class MemoryCache<TKey, TValue>(int sizeLimit = 50, int concurrencyLevel = 2)
    where TKey : notnull
    where TValue : class
{
    private readonly ConcurrentDictionary<TKey, Entry> _map = new(concurrencyLevel, capacity: sizeLimit);
 
    /// <summary>
    ///  Lock used to synchronize cache compaction operations. This prevents multiple threads
    ///  from attempting to compact the cache simultaneously while allowing concurrent reads.
    /// </summary>
    private readonly object _compactLock = new();
    private readonly int _sizeLimit = sizeLimit;
 
    /// <summary>
    ///  Optional callback invoked after cache compaction completes. Only used by tests.
    /// </summary>
    private Action? _compactedHandler;
 
    /// <summary>
    ///  Attempts to retrieve a value from the cache and updates its last access time if found.
    /// </summary>
    public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue? result)
    {
        if (_map.TryGetValue(key, out var entry))
        {
            entry.UpdateLastAccess();
            result = entry.Value;
            return true;
        }
 
        result = default;
        return false;
    }
 
    /// <summary>
    ///  Adds or updates a value in the cache. If the cache is at capacity, triggers compaction
    ///  before adding the new entry.
    /// </summary>
    public void Set(TKey key, TValue value)
    {
        CompactIfNeeded();
 
        _map[key] = new Entry(value);
    }
 
    /// <summary>
    ///  Removes approximately half of the least recently used entries when the cache reaches capacity.
    /// </summary>
    private void CompactIfNeeded()
    {
        // Fast path: check size without locking
        if (_map.Count < _sizeLimit)
        {
            return;
        }
 
        lock (_compactLock)
        {
            // Double-check after acquiring lock in case another thread already compacted
            if (_map.Count < _sizeLimit)
            {
                return;
            }
 
            // Create a snapshot with last access times to implement approximate LRU eviction.
            // This captures each entry's access time to determine which entries were least recently used.
            var orderedItems = _map.ToArray().SelectAndOrderByAsArray(
                selector: static x => (x.Key, x.Value.LastAccess),
                keySelector: static x => x.LastAccess);
 
            var toRemove = Math.Max(_sizeLimit / 2, 1);
 
            // Remove up to half of the oldest entries using an atomic remove-then-check pattern.
            // This ensures we don't remove entries that were accessed after our snapshot was taken.
            foreach (var (itemKey, itemLastAccess) in orderedItems)
            {
                // Atomic remove-then-check pattern eliminates race conditions
                // Note: If TryRemove fails, another thread already removed this entry.
                if (_map.TryRemove(itemKey, out var removedEntry))
                {
                    if (removedEntry.LastAccess == itemLastAccess)
                    {
                        // Entry was still old when removed - successful eviction
                        toRemove--;
 
                        // Stop early if we've removed enough entries
                        if (toRemove == 0)
                        {
                            break;
                        }
                    }
                    else
                    {
                        // Entry was accessed after snapshot - try to restore it
                        // If TryAdd fails, another thread already added a new entry with this key,
                        // which is acceptable - we preserve the hot entry's data either way
                        _map.TryAdd(itemKey, removedEntry);
                    }
                }
            }
 
            _compactedHandler?.Invoke();
        }
    }
 
    /// <summary>
    ///  Removes all entries from the cache.
    /// </summary>
    public void Clear()
        => _map.Clear();
}