File: Telemetry\PersistenceChannel\StorageService.cs
Web Access
Project: ..\..\..\src\Cli\dotnet\dotnet.csproj (dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable disable
 
using System.Globalization;
using Microsoft.ApplicationInsights.Channel;
using Microsoft.DotNet.Configurer;
 
namespace Microsoft.DotNet.Cli.Telemetry.PersistenceChannel;
 
internal sealed class StorageService : BaseStorageService
{
    private const string DefaultStorageFolderName = "TelemetryStorageService";
    private readonly FixedSizeQueue<string> _deletedFilesQueue = new(10);
 
    private readonly object _peekLockObj = new();
    private readonly object _storageFolderLock = new();
    private string _storageDirectoryPath;
    private string _storageDirectoryPathUsed;
    private long _storageCountFiles;
    private bool _storageFolderInitialized;
    private long _storageSize;
    private uint _transmissionsDropped;
 
    /// <summary>
    ///     Gets the storage's folder name.
    /// </summary>
    internal override string StorageDirectoryPath => _storageDirectoryPath;
 
    /// <summary>
    ///     Gets the storage folder. If storage folder couldn't be created, null will be returned.
    /// </summary>
    private string StorageFolder
    {
        get
        {
            if (!_storageFolderInitialized)
            {
                lock (_storageFolderLock)
                {
                    if (!_storageFolderInitialized)
                    {
                        try
                        {
                            _storageDirectoryPathUsed = _storageDirectoryPath;
 
                            if (!Directory.Exists(_storageDirectoryPathUsed))
                            {
                                Directory.CreateDirectory(_storageDirectoryPathUsed);
                            }
                        }
                        catch (Exception e)
                        {
                            _storageDirectoryPathUsed = null;
                            PersistenceChannelDebugLog.WriteException(e, "Failed to create storage folder");
                        }
 
                        _storageFolderInitialized = true;
                    }
                }
            }
 
            return _storageDirectoryPathUsed;
        }
    }
 
    internal override void Init(string storageDirectoryPath)
    {
        PeekedTransmissions = new SnapshottingDictionary<string, string>();
 
        VerifyOrSetDefaultStorageDirectoryPath(storageDirectoryPath);
 
        CapacityInBytes = 10 * 1024 * 1024; // 10 MB
        MaxFiles = 100;
 
        Task.Run(DeleteObsoleteFiles)
            .ContinueWith(
                task =>
                {
                    PersistenceChannelDebugLog.WriteException(
                        task.Exception,
                        "Storage: Unhandled exception in DeleteObsoleteFiles");
                },
                TaskContinuationOptions.OnlyOnFaulted);
    }
 
    private void VerifyOrSetDefaultStorageDirectoryPath(string desireStorageDirectoryPath)
    {
        if (string.IsNullOrEmpty(desireStorageDirectoryPath))
        {
            _storageDirectoryPath = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath,
                DefaultStorageFolderName);
        }
        else
        {
            if (!Path.IsPathRooted(desireStorageDirectoryPath))
            {
                throw new ArgumentException($"{nameof(desireStorageDirectoryPath)} need to be rooted (full path)");
            }
 
            _storageDirectoryPath = desireStorageDirectoryPath;
        }
    }
 
    /// <summary>
    ///     Reads an item from the storage. Order is Last-In-First-Out.
    ///     When the Transmission is no longer needed (it was either sent or failed with a non-retryable error) it should be
    ///     disposed.
    /// </summary>
    internal override StorageTransmission Peek()
    {
        IEnumerable<string> files = GetFiles("*.trn", 50);
 
        lock (_peekLockObj)
        {
            foreach (string file in files)
            {
                try
                {
                    // if a file was peeked before, skip it (wait until it is disposed).  
                    if (PeekedTransmissions.ContainsKey(file) == false &&
                        _deletedFilesQueue.Contains(file) == false)
                    {
                        // Load the transmission from disk.
                        StorageTransmission storageTransmissionItem = LoadTransmissionFromFileAsync(file)
                            .ConfigureAwait(false).GetAwaiter().GetResult();
 
                        // when item is disposed it should be removed from the peeked list.
                        storageTransmissionItem.Disposing = item => OnPeekedItemDisposed(file);
 
                        // add the transmission to the list.
                        PeekedTransmissions.Add(file, storageTransmissionItem.FullFilePath);
                        return storageTransmissionItem;
                    }
                }
                catch (Exception e)
                {
                    PersistenceChannelDebugLog.WriteException(
                        e,
                        "Failed to load an item from the storage. file: {0}",
                        file);
                }
            }
        }
 
        return null;
    }
 
    internal override void Delete(StorageTransmission item)
    {
        try
        {
            if (StorageFolder == null)
            {
                return;
            }
 
            // Initial storage size calculation. 
            CalculateSize();
 
            long fileSize = GetSize(item.FileName);
            File.Delete(Path.Combine(StorageFolder, item.FileName));
 
            _deletedFilesQueue.Enqueue(item.FileName);
 
            // calculate size                
            Interlocked.Add(ref _storageSize, -fileSize);
            Interlocked.Decrement(ref _storageCountFiles);
        }
        catch (IOException e)
        {
            PersistenceChannelDebugLog.WriteException(e, "Failed to delete a file. file: {0}", item == null ? "null" : item.FullFilePath);
        }
    }
 
    internal override async Task EnqueueAsync(Transmission transmission)
    {
        try
        {
            if (transmission == null || StorageFolder == null)
            {
                return;
            }
 
            // Initial storage size calculation. 
            CalculateSize();
 
            if ((ulong)_storageSize >= CapacityInBytes || _storageCountFiles >= MaxFiles)
            {
                // if max storage capacity has reached, drop the transmission (but log every 100 lost transmissions). 
                if (_transmissionsDropped++ % 100 == 0)
                {
                    PersistenceChannelDebugLog.WriteLine("Total transmissions dropped: " + _transmissionsDropped);
                }
 
                return;
            }
 
            // Writes content to a temporary file and only then rename to avoid the Peek from reading the file before it is being written.
            // Creates the temp file name
            string tempFileName = Guid.NewGuid().ToString("N");
 
            // Now that the file got created we can increase the files count
            Interlocked.Increment(ref _storageCountFiles);
 
            // Saves transmission to the temp file
            await SaveTransmissionToFileAsync(transmission, tempFileName).ConfigureAwait(false);
 
            // Now that the file is written increase storage size. 
            long temporaryFileSize = GetSize(tempFileName);
            Interlocked.Add(ref _storageSize, temporaryFileSize);
 
            // Creates a new file name
            string now = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
            string newFileName = string.Format(CultureInfo.InvariantCulture, "{0}_{1}.trn", now, tempFileName);
 
            // Renames the file
            File.Move(Path.Combine(StorageFolder, tempFileName), Path.Combine(StorageFolder, newFileName));
        }
        catch (Exception e)
        {
            PersistenceChannelDebugLog.WriteException(e, "EnqueueAsync");
        }
    }
 
    private async Task SaveTransmissionToFileAsync(Transmission transmission, string file)
    {
        try
        {
            using (Stream stream = File.OpenWrite(Path.Combine(StorageFolder, file)))
            {
                await StorageTransmission.SaveAsync(transmission, stream).ConfigureAwait(false);
            }
        }
        catch (UnauthorizedAccessException)
        {
            string message =
                string.Format(
                    "Failed to save transmission to file. UnauthorizedAccessException. File path: {0}, FileName: {1}",
                    StorageFolder, file);
            PersistenceChannelDebugLog.WriteLine(message);
            throw;
        }
    }
 
    private async Task<StorageTransmission> LoadTransmissionFromFileAsync(string file)
    {
        try
        {
            using (Stream stream = File.OpenRead(Path.Combine(StorageFolder, file)))
            {
                StorageTransmission storageTransmissionItem =
                    await StorageTransmission.CreateFromStreamAsync(stream, file).ConfigureAwait(false);
                return storageTransmissionItem;
            }
        }
        catch (Exception e)
        {
            string message =
                string.Format(
                    "Failed to load transmission from file. File path: {0}, FileName: {1}, Exception: {2}",
                    "storageFolderName", file, e);
            PersistenceChannelDebugLog.WriteLine(message);
            throw;
        }
    }
 
    /// <summary>
    ///     Get files from <see cref="storageFolder" />.
    /// </summary>
    /// <param name="fileQuery">Define the logic for sorting the files.</param>
    /// <param name="filterByExtension">Defines a file extension. This method will return only files with this extension.</param>
    /// <param name="top">
    ///     Define how many files to return. This can be useful when the directory has a lot of files, in that case
    ///     GetFilesAsync will have a performance hit.
    /// </param>
    private IEnumerable<string> GetFiles(string filterByExtension, int top)
    {
        try
        {
            if (StorageFolder != null)
            {
                return Directory.GetFiles(StorageFolder, filterByExtension).Take(top);
            }
        }
        catch (Exception e)
        {
            PersistenceChannelDebugLog.WriteException(e, "Peek failed while get files from storage.");
        }
 
        return [];
    }
 
    /// <summary>
    ///     Gets a file's size.
    /// </summary>
    private long GetSize(string file)
    {
        using (FileStream stream = File.OpenRead(Path.Combine(StorageFolder, file)))
        {
            return stream.Length;
        }
    }
 
    /// <summary>
    ///     Check the storage limits and return true if they reached.
    ///     Storage limits are defined by the number of files and the total size on disk.
    /// </summary>
    private void CalculateSize()
    {
        string[] storageFiles = Directory.GetFiles(StorageFolder, "*.*");
 
        _storageCountFiles = storageFiles.Count();
 
        long storageSizeInBytes = 0;
        foreach (string file in storageFiles)
        {
            storageSizeInBytes += GetSize(file);
        }
 
        _storageSize = storageSizeInBytes;
    }
 
    /// <summary>
    ///     Enqueue is saving a transmission to a file with a guid, and after a successful write operation it renames it to a
    ///     <c>trn</c> file.
    ///     A file without a <c>trn</c> extension is ignored by Storage.Peek(), so if a process is taken down before rename
    ///     happens it will stay on the disk forever.
    ///     This thread deletes files with the <c>trn</c> extension that exists on disk for more than 5 minutes.
    /// </summary>
    private void DeleteObsoleteFiles()
    {
        try
        {
            IEnumerable<string> files = GetFiles("*.trn", 50);
            foreach (string file in files)
            {
                DateTime creationTime = File.GetCreationTimeUtc(Path.Combine(StorageFolder, file));
                // if the file is older then 5 minutes - delete it.
                if (DateTime.UtcNow - creationTime >= TimeSpan.FromMinutes(5))
                {
                    File.Delete(Path.Combine(StorageFolder, file));
                }
            }
        }
        catch (Exception e)
        {
            PersistenceChannelDebugLog.WriteException(e, "Failed to delete tmp files.");
        }
    }
}