File: TrackedDependencies\DependencyTableCache.cs
Web Access
Project: ..\..\..\src\Utilities\Microsoft.Build.Utilities.csproj (Microsoft.Build.Utilities.Core)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#if FEATURE_FILE_TRACKER

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
 
#nullable disable

namespace Microsoft.Build.Utilities
{
    /// <summary>
    /// A static cache that will hold the dependency graph as built from tlog files.
    /// The cache is keyed on the root marker created from the full paths of the tlog files concerned.
    /// As an entry is added to the cache so is the datetime it was added.
    /// </summary>
    internal static class DependencyTableCache
    {
        private static readonly char[] s_numerals = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
        private static readonly TaskItemItemSpecIgnoreCaseComparer s_taskItemComparer = new TaskItemItemSpecIgnoreCaseComparer();
 
        /// <summary>
        /// The dictionary that maps the root of the tlog filenames to the dependencytable built from their content
        /// </summary>
        internal static Dictionary<string, DependencyTableCacheEntry> DependencyTable { get; } = new Dictionary<string, DependencyTableCacheEntry>(StringComparer.OrdinalIgnoreCase);
#pragma warning disable format // region formatting is different in net7.0 and net472, and cannot be fixed for both
        #region Methods
        /// <summary>
        /// Determine if a cache entry is up to date
        /// </summary>
        /// <param name="dependencyTable">The cache entry to check</param>
        /// <returns>true if up to date</returns>
        private static bool DependencyTableIsUpToDate(DependencyTableCacheEntry dependencyTable)
        {
            DateTime tableTime = dependencyTable.TableTime;
 
            foreach (ITaskItem tlogFile in dependencyTable.TlogFiles)
            {
                string tlogFilename = FileUtilities.NormalizePath(tlogFile.ItemSpec);
 
                DateTime lastWriteTime = NativeMethodsShared.GetLastWriteFileUtcTime(tlogFilename);
                if (lastWriteTime > tableTime)
                {
                    // one of the tlog files is newer than the table, so return false
                    return false;
                }
            }
 
            return true;
        }
 
        /// <summary>
        /// Get the cached entry for the given tlog set, if the table is out of date it is removed from the cache
        /// </summary>
        /// <param name="tLogRootingMarker">The rooting marker for the set of tlogs</param>
        /// <returns>The cached table entry</returns>
        internal static DependencyTableCacheEntry GetCachedEntry(string tLogRootingMarker)
        {
            if (DependencyTable.TryGetValue(tLogRootingMarker, out DependencyTableCacheEntry cacheEntry))
            {
                if (DependencyTableIsUpToDate(cacheEntry))
                {
                    return cacheEntry;
                }
                else
                {
                    // Remove the cached entry from memory
                    DependencyTable.Remove(tLogRootingMarker);
                }
            }
            // Either there was no cache entry, or it was out of date and was removed
            return null;
        }
 
        /// <summary>
        /// Given a set of TLog names, formats a rooting marker from them, that additionally replaces
        /// all PIDs and TIDs with "[ID]" so the cache doesn't get overloaded with entries
        /// that should be basically the same but have different PIDs or TIDs in the name.
        /// </summary>
        /// <param name="tlogFiles">The set of tlogs to format</param>
        /// <returns>The normalized rooting marker based on that set of tlogs</returns>
        internal static string FormatNormalizedTlogRootingMarker(ITaskItem[] tlogFiles)
        {
            var normalizedFiles = new HashSet<ITaskItem>(s_taskItemComparer);
 
            for (int i = 0; i < tlogFiles.Length; i++)
            {
                ITaskItem normalizedFile = new TaskItem(tlogFiles[i]);
                normalizedFile.ItemSpec = NormalizeTlogPath(tlogFiles[i].ItemSpec);
                normalizedFiles.Add(normalizedFile);
            }
 
            string normalizedRootingMarker = FileTracker.FormatRootingMarker(normalizedFiles.ToArray());
            return normalizedRootingMarker;
        }
 
        /// <summary>
        /// Given a TLog path, replace all PIDs and TIDs with "[ID]" in the filename, where
        /// the typical format of a filename is "tool[.PID][-tool].read/write/command/delete.TID.tlog"
        /// </summary>
        /// <comments>
        /// The algorithm used finds all instances of .\d+. and .\d+- in the filename and translates them
        /// to .[ID]. and .[ID]- respectively, where "filename" is defined as the part of the path following
        /// the final '\' in the path.
        ///
        /// In the VS 2010 C++ project system, there are artificially constructed tlogs that instead follow the
        /// pattern "ProjectName.read/write.1.tlog", which means that one result of this change is that such
        /// tlogs, should the project name also contain this pattern (e.g. ClassLibrary.1.csproj), will also end up
        /// with [ID] being substituted for digits in the project name itself -- so the tlog name would end up being
        /// ClassLibrary.[ID].read.[ID].tlog, rather than ClassLibrary.1.read.[ID].tlog.  This could potentially
        /// cause issues if there are multiple projects differentiated only by the digits in their names; however
        /// we believe this is not an interesting scenario to watch for and support, given that the resultant rooting
        /// marker is constructed from full paths, so either:
        /// - The project directories are also different, and are never substituted, leading to different full paths (e.g.
        ///   C:\ClassLibrary.1\Debug\ClassLibrary.[ID].read.[ID].tlog and C:\ClassLibrary.2\Debug\ClassLibrary.[ID].read.[ID].tlog)
        /// - The project directories are the same, in which case there are two projects that share the same intermediate
        ///   directory, which has a host of other problems and is explicitly NOT a supported scenario.
        /// </comments>
        /// <param name="tlogPath">The tlog path to normalize</param>
        /// <returns>The normalized path</returns>
        private static string NormalizeTlogPath(string tlogPath)
        {
            if (tlogPath.IndexOfAny(s_numerals) == -1)
            {
                // no reason to make modifications if there aren't any numerical IDs in the
                // log filename to begin with.
                return tlogPath;
            }
            else
            {
                int i;
                StringBuilder normalizedTlogFilename = new StringBuilder();
 
                // We're walking the filename backwards since once we hit the final '\', we know we can stop parsing.
                // So as to avoid allocating more memory and/or forcing StringBuilder to do more character copies
                // than necessary, we append the reversed filename character by character to its own StringBuilder,
                // and then reverse it again when constructing the final normalized path.
                for (i = tlogPath.Length - 1; i >= 0 && tlogPath[i] != '\\'; i--)
                {
                    // final character in the pattern can be either '.' or '-'
                    if (tlogPath[i] == '.' || tlogPath[i] == '-')
                    {
                        normalizedTlogFilename.Append(tlogPath[i]);
 
                        int j = i - 1;
                        // to match the pattern, all preceding characters must be numeric
                        while (j >= 0 && tlogPath[j] != '\\' && tlogPath[j] >= '0' && tlogPath[j] <= '9')
                        {
                            j--;
                        }
 
                        // and the pattern must begin with '.'
                        if (j >= 0 && tlogPath[j] == '.')
                        {
                            // [ID] backwards. :)
                            normalizedTlogFilename.Append("]DI[");
                            normalizedTlogFilename.Append(tlogPath[j]);
                            i = j;
                        }
                    }
                    else
                    {
                        // append this character -- it's not interesting.
                        normalizedTlogFilename.Append(tlogPath[i]);
                    }
                }
 
                StringBuilder normalizedTlogPath = new StringBuilder(i + normalizedTlogFilename.Length);
 
                if (i >= 0)
                {
                    // If we bailed out early, add everything else before reversing the filename itself
                    normalizedTlogPath.Append(tlogPath, 0, i + 1);
                }
 
                // now add the reversed filename
                for (int k = normalizedTlogFilename.Length - 1; k >= 0; k--)
                {
                    normalizedTlogPath.Append(normalizedTlogFilename[k]);
                }
 
                return normalizedTlogPath.ToString();
            }
        }
 
        #endregion

        #region TaskItemItemSpecIgnoreCaseComparer

        /// <summary>
        /// EqualityComparer for ITaskItems that only looks at the itemspec
        /// </summary>
        private class TaskItemItemSpecIgnoreCaseComparer : IEqualityComparer<ITaskItem>
        {
            /// <summary>
            /// Returns whether the two ITaskItems are equal, where they are judged to be
            /// equal as long as the itemspecs, compared case-insensitively, are equal.
            /// </summary>
            public bool Equals(ITaskItem x, ITaskItem y)
            {
                if (ReferenceEquals(x, y))
                {
                    return true;
                }
 
                if (x is null || y is null)
                {
                    return false;
                }
 
                return string.Equals(x.ItemSpec, y.ItemSpec, StringComparison.OrdinalIgnoreCase);
            }
 
            /// <summary>
            /// Returns the hashcode of this ITaskItem.  Given that equality is judged solely based
            /// on the itemspec, the hash code for this particular comparer also only uses the
            /// itemspec to make its determination.
            /// </summary>
            public int GetHashCode(ITaskItem obj) => obj == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.ItemSpec);
        }
 
        #endregion
    }
#pragma warning restore format

    /// <summary>
    /// A cache entry
    /// </summary>
    internal class DependencyTableCacheEntry
    {
        // the set of tlog files used to build this cache entry
        public ITaskItem[] TlogFiles { get; }
 
        public DateTime TableTime { get; }
 
        public IDictionary DependencyTable { get; }
 
        /// <summary>
        /// Construct a new entry
        /// </summary>
        /// <param name="tlogFiles">The tlog files used to build this dependency table</param>
        /// <param name="dependencyTable">The dependency table to be cached</param>
        internal DependencyTableCacheEntry(ITaskItem[] tlogFiles, IDictionary dependencyTable)
        {
            TlogFiles = new ITaskItem[tlogFiles.Length];
            TableTime = DateTime.MinValue;
 
            // Our cache's knowledge of the tlog items needs their full path
            for (int tlogItemCount = 0; tlogItemCount < tlogFiles.Length; tlogItemCount++)
            {
                string tlogFilename = FileUtilities.NormalizePath(tlogFiles[tlogItemCount].ItemSpec);
                TlogFiles[tlogItemCount] = new TaskItem(tlogFilename);
                // Our cache entry needs to use the last modified time of the latest tlog
                // involved so that our cache can be invalidated if any tlog is updated
                DateTime modifiedTime = NativeMethodsShared.GetLastWriteFileUtcTime(tlogFilename);
                if (modifiedTime > TableTime)
                {
                    TableTime = modifiedTime;
                }
            }
 
            DependencyTable = dependencyTable;
        }
    }
}
 
#endif