File: Execution\TestRunCache.cs
Web Access
Project: src\src\vstest\src\Microsoft.TestPlatform.CrossPlatEngine\Microsoft.TestPlatform.CrossPlatEngine.csproj (Microsoft.TestPlatform.CrossPlatEngine)
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;

using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;

namespace Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Execution;

/// <summary>
/// Maintains a cache of last 'n' test results and maintains stats for the complete run.
/// </summary>
internal class TestRunCache : ITestRunCache
{
    /// <summary>
    /// Specifies whether the object is disposed or not.
    /// </summary>
    private bool _isDisposed;

    /// <summary>
    /// Test run stats
    /// </summary>
    private readonly Dictionary<TestOutcome, long> _runStats;

    /// <summary>
    /// Total tests which have currently executed
    /// </summary>
    private long _totalExecutedTests;

    /// <summary>
    /// Callback used when cache is ready to report some test results/case.
    /// </summary>
    private readonly OnCacheHit _onCacheHit;

    /// <summary>
    /// Max size of the test result buffer
    /// </summary>
    private readonly long _cacheSize;

    /// <summary>
    /// Timeout that triggers sending results regardless of cache size.
    /// </summary>
    private readonly TimeSpan _cacheTimeout;

    /// <summary>
    /// Timer for cache
    /// </summary>
    private readonly Timer _timer;

    /// <summary>
    /// Last time results were sent.
    /// </summary>
    private DateTime _lastUpdate;

    /// <summary>
    /// The test case currently in progress.
    /// </summary>
    private ICollection<TestCase> _inProgressTests;

    /// <summary>
    /// Test results buffer
    /// </summary>
    private ICollection<TestResult> _testResults;

    /// <summary>
    /// Sync object
    /// </summary>
    private readonly object _syncObject;

    /// <summary>
    /// Initializes a new instance of the <see cref="TestRunCache"/> class.
    /// </summary>
    /// <param name="cacheSize"> The cache size. </param>
    /// <param name="cacheTimeout"> The cache timeout. </param>
    /// <param name="onCacheHit"> The on cache hit. </param>
    internal TestRunCache(long cacheSize, TimeSpan cacheTimeout, OnCacheHit onCacheHit)
    {
        TPDebug.Assert(cacheSize > 0, "Buffer size cannot be less than zero");
        TPDebug.Assert(onCacheHit != null, "Callback which listens for cache size limit cannot be null.");
        TPDebug.Assert(cacheTimeout > TimeSpan.MinValue, "The cache timeout must be greater than min value.");

        if (cacheTimeout.TotalMilliseconds > int.MaxValue)
        {
            cacheTimeout = TimeSpan.FromMilliseconds(int.MaxValue / 2);
        }

        _cacheSize = cacheSize;
        _onCacheHit = onCacheHit;
        _lastUpdate = DateTime.Now;
        _cacheTimeout = cacheTimeout;
        _inProgressTests = new Collection<TestCase>();
        _testResults = new Collection<TestResult>();
        _runStats = new Dictionary<TestOutcome, long>();
        _syncObject = new object();

        _timer = new Timer(OnCacheTimeHit, this, cacheTimeout, cacheTimeout);
    }

    /// <summary>
    /// Called when the cache is ready to report on the current status.
    /// </summary>
    internal delegate void OnCacheHit(TestRunStatistics testRunStats, ICollection<TestResult> results, ICollection<TestCase> inProgressTests);

    /// <summary>
    /// Gets the test results present in the cache currently.
    /// </summary>
    public ICollection<TestResult> TestResults
    {
        get
        {
            lock (_syncObject)
            {
                return _testResults;
            }
        }
    }

    /// <summary>
    /// Gets the set of in-progress test cases present in the cache currently.
    /// </summary>
    public ICollection<TestCase> InProgressTests
    {
        get
        {
            lock (_syncObject)
            {
                return _inProgressTests;
            }
        }
    }

    /// <summary>
    /// Gets the total executed tests.
    /// </summary>
    public long TotalExecutedTests
    {
        get
        {
            lock (_syncObject)
            {
                return _totalExecutedTests;
            }
        }
    }

    /// <summary>
    /// Gets the test run stats
    /// </summary>
    public TestRunStatistics TestRunStatistics
    {
        get
        {
            lock (_syncObject)
            {
                var stats = new TestRunStatistics(new Dictionary<TestOutcome, long>(_runStats));
                stats.ExecutedTests = TotalExecutedTests;

                return stats;
            }
        }
    }

    public IDictionary<string, int> AdapterTelemetry { get; set; } = new Dictionary<string, int>();
    /// <summary>
    /// Disposes the cache
    /// </summary>
    public void Dispose()
    {
        Dispose(true);

        // Use SupressFinalize in case a subclass
        // of this valueType implements a finalizer.
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Notifies the cache that a test is starting.
    /// This notification comes from the adapter to the engine which then calls into the cache.
    /// </summary>
    /// <param name="testCase"> The test Case. </param>
    public void OnTestStarted(TestCase testCase)
    {
        lock (_syncObject)
        {
            _inProgressTests.Add(testCase);

            CheckForCacheHit();
        }
    }

    /// <summary>
    /// Notifies the cache of a new test result from the adapter.
    /// </summary>
    /// <param name="testResult"> The test result. </param>
    public void OnNewTestResult(TestResult testResult)
    {
        lock (_syncObject)
        {
            _totalExecutedTests++;
            _testResults.Add(testResult);
            MsTestV1TelemetryHelper.AddTelemetry(testResult, AdapterTelemetry);

            if (_runStats.TryGetValue(testResult.Outcome, out long count))
            {
                count++;
            }
            else
            {
                count = 1;
            }

            _runStats[testResult.Outcome] = count;

            RemoveInProgress(testResult);

            CheckForCacheHit();
        }
    }

    /// <summary>
    /// Notifies the cache of a test completion.
    /// </summary>
    /// <param name="completedTest">
    /// The completed Test.
    /// </param>
    /// <returns> True if this test has been removed from the list of in progress tests. </returns>
    public bool OnTestCompletion(TestCase? completedTest)
    {
        lock (_syncObject)
        {
            if (completedTest == null)
            {
                EqtTrace.Warning("TestRunCache: completedTest is null");
                return false;
            }

            if (_inProgressTests == null || _inProgressTests.Count == 0)
            {
                EqtTrace.Warning("TestRunCache: InProgressTests is null");
                return false;
            }

            var removed = _inProgressTests.Remove(completedTest);
            if (removed)
            {
                return true;
            }

            // Try finding/removing a matching test corresponding to the completed test
            var inProgressTest = _inProgressTests.FirstOrDefault(inProgress => inProgress.Id == completedTest.Id);
            if (inProgressTest != null)
            {
                removed = _inProgressTests.Remove(inProgressTest);
            }

            return removed;
        }
    }

    /// <summary>
    /// Returns the last chunk
    /// </summary>
    /// <returns> The set of test results remaining in the cache. </returns>
    public ICollection<TestResult> GetLastChunk()
    {
        lock (_syncObject)
        {
            var lastChunk = _testResults;

            _testResults = new Collection<TestResult>();

            return lastChunk;
        }
    }

    /// <summary>
    /// The dispose.
    /// </summary>
    /// <param name="disposing"> Indicates if this needs to clean up managed resources. </param>
    /// <remarks>
    /// The dispose pattern is a best practice to differentiate between managed and native resources cleanup.
    /// Even though this particular class does not have any native resources - honoring the pattern to be consistent throughout the code base.
    /// </remarks>
    protected virtual void Dispose(bool disposing)
    {
        // If you need thread safety, use a lock around these
        // operations, as well as in your methods that use the resource.
        if (disposing && !_isDisposed)
        {
            if (_timer != null)
            {
                _timer.Dispose();
            }

            // Indicate that the instance has been disposed.
            _isDisposed = true;
        }
    }

    /// <summary>
    /// Checks if the cache timeout/size has been met.
    /// </summary>
    private void CheckForCacheHit()
    {
        lock (_syncObject)
        {
            // Send results when the specified cache size has been reached or
            // after the specified cache timeout has been hit.
            var timeDelta = DateTime.Now - _lastUpdate;

            var inProgressTestsCount = _inProgressTests.Count;

            if ((_testResults.Count + inProgressTestsCount) >= _cacheSize || (timeDelta >= _cacheTimeout && inProgressTestsCount > 0))
            {
                SendResults();
            }
        }
    }

    private void CheckForCacheHitOnTimer()
    {
        lock (_syncObject)
        {
            if (_testResults.Count > 0 || _inProgressTests.Count > 0)
            {
                SendResults();
            }
        }
    }

    private void SendResults()
    {
        // Pass on the buffer to the listener and clear the old one
        _onCacheHit(TestRunStatistics, _testResults, _inProgressTests);
        _testResults = new Collection<TestResult>();
        _inProgressTests = new Collection<TestCase>();
        _lastUpdate = DateTime.Now;

        // Reset the timer
        _timer.Change(_cacheTimeout, _cacheTimeout);

        EqtTrace.Verbose("TestRunCache: OnNewTestResult: Notified the onCacheHit callback.");
    }

    private void OnCacheTimeHit(object? state)
    {
        lock (_syncObject)
        {
            try
            {
                CheckForCacheHitOnTimer();
            }
            catch (Exception ex)
            {
                EqtTrace.Error("TestRunCache: OnCacheTimeHit: Exception occurred while checking for cache hit. {0}", ex);
            }
        }
    }

    private void RemoveInProgress(TestResult result)
    {
        var removed = OnTestCompletion(result.TestCase);
        if (!removed)
        {
            EqtTrace.Warning("TestRunCache: No test found corresponding to testResult '{0}' in inProgress list.", result.DisplayName);
        }
    }

}