File: ServiceClient\DashboardRunStore.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.IO.Hashing;
using System.Text;
using System.Text.Json;
using Aspire.Dashboard.Configuration;
using Aspire.Shared;
using Microsoft.Extensions.Options;
 
namespace Aspire.Dashboard.ServiceClient;
 
/// <summary>
/// Provides the dashboard runs available for selection.
/// </summary>
public interface IDashboardRunStore
{
    /// <summary>
    /// Gets a value indicating whether historical dashboard runs can be selected.
    /// </summary>
    bool SupportsRunSelection { get; }
 
    /// <summary>
    /// Gets the current and historical dashboard runs available for selection.
    /// </summary>
    /// <returns>The available dashboard runs.</returns>
    IReadOnlyList<DashboardRunDescriptor> GetRuns();
 
    /// <summary>
    /// Gets the current dashboard run.
    /// </summary>
    /// <returns>The current dashboard run.</returns>
    DashboardRunDescriptor GetCurrentRun();
 
    /// <summary>
    /// Gets the dashboard run with the specified ID.
    /// </summary>
    /// <param name="runId">The ID of the dashboard run.</param>
    /// <returns>The dashboard run, or <see langword="null"/> when the run is not available.</returns>
    DashboardRunDescriptor? GetRunById(string runId);
 
    /// <summary>
    /// Pins or unpins the specified dashboard run.
    /// </summary>
    /// <param name="run">The dashboard run to update.</param>
    /// <param name="isPinned"><see langword="true"/> to pin the dashboard run; <see langword="false"/> to unpin it.</param>
    void SetRunPinned(DashboardRunDescriptor run, bool isPinned);
 
    /// <summary>
    /// Attempts to acquire a lease that keeps the specified dashboard run available while it is selected.
    /// </summary>
    /// <param name="run">The dashboard run to lease.</param>
    /// <returns>A lease for the dashboard run, or <see langword="null"/> when the run is no longer available.</returns>
    IDisposable? TryAcquireRunLease(DashboardRunDescriptor run);
 
    /// <summary>
    /// Publishes the current dashboard run so it can be discovered by future dashboard processes.
    /// </summary>
    void PublishRun();
 
    /// <summary>
    /// Deletes dashboard runs beyond the retention limit.
    /// </summary>
    void PruneExpiredRuns();
}
 
internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable
{
    private const string TemporaryDirectoryPrefix = "aspire-dashboard-";
 
    internal const string DatabaseFileName = "dashboard.db";
    internal const int MaxApplicationDirectoryNameLength = 80;
    internal const int MaxRuns = 10;
    internal const int SchemaVersion = DashboardSqliteDatabase.SchemaVersion;
 
    private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true };
 
    private readonly string? _runsDirectory;
    private readonly string? _metadataPath;
    private readonly string? _temporaryDirectory;
    private readonly FileLock? _runLock;
    private DashboardRunMetadata _metadata;
    private readonly ILogger<DashboardRunStore> _logger;
    private readonly TimeProvider _timeProvider;
    private readonly Action<string> _deleteRunDirectory;
    private readonly Lazy<IReadOnlyList<DashboardRunDescriptor>> _runs;
    private readonly object _runStateLock = new();
    private bool _metadataPublished;
 
    public DashboardRunStore(IOptions<DashboardOptions> options, ILogger<DashboardRunStore> logger, TimeProvider timeProvider)
        : this(options, logger, timeProvider, static directory => Directory.Delete(directory, recursive: true))
    {
    }
 
    internal DashboardRunStore(
        IOptions<DashboardOptions> options,
        ILogger<DashboardRunStore> logger,
        TimeProvider timeProvider,
        Action<string> deleteRunDirectory)
    {
        _logger = logger;
        _timeProvider = timeProvider;
        _deleteRunDirectory = deleteRunDirectory;
        var applicationName = string.IsNullOrWhiteSpace(options.Value.ApplicationName) ? "Aspire" : options.Value.ApplicationName;
        var startedAt = timeProvider.GetUtcNow();
        // A millisecond timestamp collision is very unlikely. The exclusive run lock below also ensures that if two
        // Dashboard instances resolve the same run ID concurrently, the second fails instead of sharing the database.
        // Format invariantly. This value is a durable directory name and the ordinal sort key used by
        // PruneRuns, so a non-Gregorian current culture (th-TH, ar-SA) would produce IDs that sort
        // against previous runs incorrectly and let retention delete newer runs.
        var runId = startedAt.ToString("yyyyMMddTHHmmssfffZ", CultureInfo.InvariantCulture);
        PersistenceMode = options.Value.Data.PersistenceMode;
 
        // Persistent data can contain environment variables, telemetry, and console logs. Restrict the
        // application directory so the database, WAL, shared-memory, and metadata files aren't exposed
        // to other local users even when the data root was created with a permissive umask.
        switch (PersistenceMode)
        {
            case DashboardPersistenceMode.None:
                _temporaryDirectory = Directory.CreateTempSubdirectory(TemporaryDirectoryPrefix).FullName;
                RunDirectory = _temporaryDirectory;
                DatabasePath = Path.Combine(RunDirectory, DatabaseFileName);
                _runLock = OpenRunLock(RunDirectory);
                DeleteAbandonedTemporaryDirectories(deleteRunDirectory);
                break;
            case DashboardPersistenceMode.Run:
                var applicationDirectory = GetApplicationDirectory(options.Value.Data.Directory, applicationName);
                DirectoryHelper.CreateWithOwnerOnlyPermissions(applicationDirectory);
                _runsDirectory = Path.Combine(applicationDirectory, "runs");
                RunDirectory = Path.Combine(_runsDirectory, runId);
                DatabasePath = Path.Combine(RunDirectory, DatabaseFileName);
                Directory.CreateDirectory(RunDirectory);
                _runLock = OpenRequiredRunLock(
                    RunDirectory,
                    $"Dashboard run '{runId}' is already in use by another dashboard process.");
                _metadataPath = Path.Combine(RunDirectory, "run.json");
                break;
            case DashboardPersistenceMode.Resume:
                RunDirectory = GetApplicationDirectory(options.Value.Data.Directory, applicationName);
                DatabasePath = Path.Combine(RunDirectory, DatabaseFileName);
                DirectoryHelper.CreateWithOwnerOnlyPermissions(RunDirectory);
                var resumeRunLock = OpenRequiredRunLock(
                    RunDirectory,
                    $"Dashboard data for application '{applicationName}' is already in use by another dashboard process. Database path: '{DatabasePath}'.");
                try
                {
                    if (!File.Exists(DatabasePath))
                    {
                        _logger.LogDebug("Creating dashboard database at '{DatabasePath}'.", DatabasePath);
                    }
                    else if (!DashboardSqliteDatabase.IsCompatible(DatabasePath))
                    {
                        _logger.LogInformation(
                            "Existing dashboard database at '{DatabasePath}' is incompatible with schema version {SchemaVersion} and will be replaced.",
                            DatabasePath,
                            SchemaVersion);
                        DeleteDatabaseFiles(DatabasePath);
                    }
                    else
                    {
                        _logger.LogDebug("Resuming dashboard database at '{DatabasePath}'.", DatabasePath);
                    }
 
                    _runLock = resumeRunLock;
                }
                catch
                {
                    resumeRunLock.Dispose();
                    throw;
                }
                break;
            default:
                throw new InvalidOperationException($"Unexpected dashboard persistence mode: {PersistenceMode}");
        }
 
        _metadata = new DashboardRunMetadata
        {
            SchemaVersion = SchemaVersion,
            RunId = runId,
            StartedAtUtc = startedAt,
            ApplicationName = options.Value.ApplicationName,
            DatabaseFileName = Path.GetFileName(DatabasePath)
        };
        _runs = new(LoadRuns);
 
        _logger.LogDebug(
            "Dashboard run store initialized with persistence mode '{PersistenceMode}'. Run directory: '{RunDirectory}'. Database path: '{DatabasePath}'.",
            PersistenceMode,
            RunDirectory,
            DatabasePath);
    }
 
    private void DeleteAbandonedTemporaryDirectories(Action<string> deleteRunDirectory)
    {
        var temporaryRoot = Directory.GetParent(RunDirectory)!.FullName;
        foreach (var directory in Directory.EnumerateDirectories(temporaryRoot, $"{TemporaryDirectoryPrefix}*"))
        {
            if (string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase) ||
                !File.Exists(Path.Combine(directory, DatabaseFileName)))
            {
                continue;
            }
 
            using var runLock = TryOpenRunLock(directory);
            if (runLock is null)
            {
                continue;
            }
 
            try
            {
                deleteRunDirectory(directory);
            }
            catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
            {
                _logger.LogWarning(
                    exception,
                    "Failed to delete abandoned dashboard temporary directory '{RunDirectory}'. The directory may still be in use by another dashboard process.",
                    directory);
            }
        }
    }
 
    public string RunDirectory { get; }
    public string DatabasePath { get; }
    public string RunId => _metadata.RunId;
    public DashboardPersistenceMode PersistenceMode { get; }
    public bool SupportsRunSelection => PersistenceMode == DashboardPersistenceMode.Run;
 
    public IReadOnlyList<DashboardRunDescriptor> GetRuns()
    {
        var runs = _runs.Value;
        return runs.Any(run => run.IsPruned || !run.IsSelectable)
            ? runs.Where(run => !run.IsPruned && run.IsSelectable).ToArray()
            : runs;
    }
 
    public DashboardRunDescriptor GetCurrentRun() => GetRuns().Single(run => run.IsCurrent);
 
    public DashboardRunDescriptor? GetRunById(string runId) =>
        GetRuns().SingleOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal));
 
    public void SetRunPinned(DashboardRunDescriptor run, bool isPinned)
    {
        var storedRun = GetRunById(run.RunId);
        if (storedRun is null)
        {
            throw new InvalidOperationException($"Dashboard run '{run.RunId}' is no longer available.");
        }
 
        var runDirectory = Path.GetDirectoryName(storedRun.DatabasePath)!;
        lock (_runStateLock)
        {
            // The current run has the store's lifetime lock, and a selected historical run has a lease.
            // Only an unselected historical run needs a temporary lock while its metadata is updated.
            using var runLock = storedRun.IsCurrent || storedRun.IsLeased
                ? null
                : TryOpenRunLock(runDirectory)
                    ?? throw new InvalidOperationException($"Dashboard run '{storedRun.RunId}' is no longer available.");
            UpdatePinnedState(storedRun, runDirectory, isPinned);
        }
    }
 
    private void UpdatePinnedState(DashboardRunDescriptor run, string runDirectory, bool isPinned)
    {
        var metadataPath = Path.Combine(runDirectory, "run.json");
        var metadata = JsonSerializer.Deserialize<DashboardRunMetadata>(File.ReadAllText(metadataPath));
        if (metadata is not { SchemaVersion: SchemaVersion } ||
            !string.Equals(metadata.RunId, run.RunId, StringComparison.Ordinal))
        {
            throw new InvalidDataException($"Dashboard run metadata for '{run.RunId}' is invalid.");
        }
 
        var updatedMetadata = metadata with { IsPinned = isPinned };
        WriteMetadata(updatedMetadata, metadataPath);
        if (string.Equals(run.RunId, RunId, StringComparison.Ordinal))
        {
            _metadata = updatedMetadata;
        }
 
        run.IsPinned = isPinned;
    }
 
    public void PublishRun()
    {
        if (_metadataPath is null || _metadataPublished)
        {
            return;
        }
 
        WriteMetadata(_metadata);
        _metadataPublished = true;
    }
 
    /// <summary>
    /// Deletes run directories beyond the retention limit.
    /// </summary>
    /// <remarks>
    /// Kept separate from <see cref="PublishRun"/> because pruning walks every run directory, takes a cross-process
    /// lock on each, and recursively deletes it. On a slow or contended file system it can take a long time, so
    /// pruning is background housekeeping and must not hold up the dashboard accepting requests.
    /// </remarks>
    public void PruneExpiredRuns()
    {
        if (!_metadataPublished || _runsDirectory is null || !Directory.Exists(_runsDirectory))
        {
            return;
        }
 
        PruneRuns(_deleteRunDirectory);
    }
 
    public IDisposable? TryAcquireRunLease(DashboardRunDescriptor run)
    {
        var storedRun = GetRunById(run.RunId);
        if (storedRun is null)
        {
            return null;
        }
 
        var runDirectory = Path.GetDirectoryName(storedRun.DatabasePath)!;
        lock (_runStateLock)
        {
            var runLock = TryOpenRunLock(runDirectory);
            if (runLock is null)
            {
                return null;
            }
 
            storedRun.IsLeased = true;
            return new RunLease(this, storedRun, runLock);
        }
    }
 
    private IReadOnlyList<DashboardRunDescriptor> LoadRuns()
    {
        var runs = new List<DashboardRunDescriptor>
        {
            CreateDescriptor(_metadata, RunDirectory, isCurrent: true)
        };
 
        if (SupportsRunSelection && Directory.Exists(_runsDirectory))
        {
            foreach (var directory in Directory.EnumerateDirectories(_runsDirectory))
            {
                if (string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase))
                {
                    continue;
                }
 
                var metadataPath = Path.Combine(directory, "run.json");
                try
                {
                    var metadata = JsonSerializer.Deserialize<DashboardRunMetadata>(File.ReadAllText(metadataPath));
                    if (metadata is { SchemaVersion: SchemaVersion })
                    {
                        var run = CreateDescriptor(metadata, directory, isCurrent: false);
                        // Filter out in-progress runs that are owned by other Dashboard instances.
                        using var runLock = TryOpenRunLock(directory);
                        run.IsSelectable = runLock is not null;
                        runs.Add(run);
                    }
                }
                catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
                {
                    // Ignore incomplete or unreadable run metadata.
                }
            }
        }
 
        var orderedRuns = runs
            .OrderByDescending(run => run.IsCurrent)
            .ThenByDescending(run => run.IsPinned)
            .ThenByDescending(run => run.StartedAtUtc)
            .ToArray();
        _logger.LogDebug(
            "Dashboard run discovery completed in directory '{RunsDirectory}'. Run count: {RunCount}. Run IDs: {RunIds}.",
            _runsDirectory ?? RunDirectory,
            orderedRuns.Length,
            string.Join(", ", orderedRuns.Select(run => run.RunId)));
 
        return orderedRuns;
    }
 
    public void Dispose()
    {
        try
        {
            if (_metadataPublished)
            {
                WriteMetadata(_metadata with { EndedAtUtc = _timeProvider.GetUtcNow(), CleanShutdown = true });
            }
            else if (_temporaryDirectory is not null && Directory.Exists(_temporaryDirectory))
            {
                Directory.Delete(_temporaryDirectory, recursive: true);
            }
            else if (PersistenceMode == DashboardPersistenceMode.Run && Directory.Exists(RunDirectory))
            {
                // Run metadata is published only after the host starts listening. If startup fails first, remove
                // the initialized database so the attempted run never appears as empty historical data.
                _deleteRunDirectory(RunDirectory);
            }
        }
        finally
        {
            _runLock?.Dispose();
        }
    }
 
    private void WriteMetadata(DashboardRunMetadata metadata) => WriteMetadata(metadata, _metadataPath!);
 
    private static void WriteMetadata(DashboardRunMetadata metadata, string metadataPath)
    {
        // Write to a sibling temp file and rename over the target. Overwriting run.json in place means a
        // crash or power loss part-way through leaves a truncated file and the run becomes unreadable on
        // the next start. A rename within the same directory is atomic on both Windows and Unix.
        var temporaryPath = $"{metadataPath}.tmp";
 
        try
        {
            File.WriteAllText(temporaryPath, JsonSerializer.Serialize(metadata, s_jsonOptions));
            File.Move(temporaryPath, metadataPath, overwrite: true);
        }
        catch
        {
            try
            {
                File.Delete(temporaryPath);
            }
            catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
            {
                // Best effort. A leftover temp file doesn't affect run discovery, which only reads run.json.
            }
 
            throw;
        }
    }
 
    private void PruneRuns(Action<string> deleteRunDirectory)
    {
        foreach (var run in _runs.Value.Where(run => !run.IsPinned).Skip(MaxRuns))
        {
            var directory = Path.GetDirectoryName(run.DatabasePath)!;
            using var runLock = TryOpenRunLock(directory);
            // Pinning can happen after the candidate list is created. Recheck while holding the same lock used by
            // SetRunPinned so a successful pin always completes before pruning decides whether to delete the run.
            if (runLock is null || IsPinnedRunDirectory(directory))
            {
                continue;
            }
 
            try
            {
                deleteRunDirectory(directory);
                run.IsPruned = true;
            }
            catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
            {
                _logger.LogWarning(
                    exception,
                    "Failed to delete expired dashboard run directory '{RunDirectory}'. The directory may still be in use by another dashboard process.",
                    directory);
            }
        }
    }
 
    private static bool IsPinnedRunDirectory(string runDirectory)
    {
        try
        {
            var metadataPath = Path.Combine(runDirectory, "run.json");
            return JsonSerializer.Deserialize<DashboardRunMetadata>(File.ReadAllText(metadataPath))?.IsPinned == true;
        }
        catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
        {
            // Run metadata is written locally by DashboardRunStore and is assumed to be reliable during normal usage.
            // Treat unreadable metadata as unpinned so incomplete or abandoned run directories can still be pruned.
            return false;
        }
    }
 
    private static FileLock OpenRunLock(string runDirectory)
    {
        // Keep the lock beside the run directory so pruning can hold it while recursively deleting the directory on Windows.
        return FileLock.Acquire(GetRunLockPath(runDirectory));
    }
 
    private static FileLock OpenRequiredRunLock(string runDirectory, string errorMessage)
    {
        try
        {
            return OpenRunLock(runDirectory);
        }
        catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
        {
            throw new InvalidOperationException(errorMessage, exception);
        }
    }
 
    private static FileLock? TryOpenRunLock(string runDirectory)
    {
        var runLock = FileLock.TryAcquire(GetRunLockPath(runDirectory));
        if (runLock is null)
        {
            return null;
        }
 
        // The lock file is adjacent to the run directory, so OpenOrCreate can recreate it after pruning has already
        // deleted the directory. Check after acquiring the lock to avoid racing with a cooperating pruner.
        if (!Directory.Exists(runDirectory))
        {
            runLock.Dispose();
            return null;
        }
 
        return runLock;
    }
 
    internal static string GetRunLockPath(string runDirectory) => $"{runDirectory}.lock";
 
    private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata metadata, string runDirectory, bool isCurrent)
    {
        return new DashboardRunDescriptor(
            metadata.RunId,
            metadata.SchemaVersion,
            metadata.StartedAtUtc,
            metadata.EndedAtUtc,
            metadata.CleanShutdown,
            metadata.ApplicationName,
            Path.Combine(runDirectory, metadata.DatabaseFileName),
            isCurrent)
        {
            IsPinned = metadata.IsPinned
        };
    }
 
    internal static string GetApplicationDirectory(string? dataRoot, string applicationName)
    {
        if (string.IsNullOrWhiteSpace(dataRoot))
        {
            dataRoot = Path.Combine(AspireHomeDirectory.GetDefault(), "dashboard");
        }
 
        return Path.Combine(Path.GetFullPath(dataRoot), GetApplicationDirectoryName(applicationName));
    }
 
    private static void DeleteDatabaseFiles(string databasePath)
    {
        foreach (var path in new[] { databasePath, $"{databasePath}-wal", $"{databasePath}-shm" })
        {
            File.Delete(path);
        }
    }
 
    internal static string GetApplicationDirectoryName(string applicationName)
    {
        ArgumentException.ThrowIfNullOrEmpty(applicationName);
 
        const int hashLength = 16;
        const int separatorLength = 1;
        var maxPrefixLength = MaxApplicationDirectoryNameLength - separatorLength - hashLength;
        var prefixBuilder = new StringBuilder(Math.Min(applicationName.Length, maxPrefixLength));
 
        foreach (var character in applicationName)
        {
            if (prefixBuilder.Length == maxPrefixLength)
            {
                break;
            }
 
            prefixBuilder.Append(character is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '-' or '_'
                ? character
                : '-');
        }
 
        var prefix = prefixBuilder.ToString().Trim('-', '_');
        if (prefix.Length == 0)
        {
            prefix = "dashboard";
        }
 
        var hash = Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(applicationName))).ToLowerInvariant();
        return $"{prefix}-{hash}";
    }
 
    private sealed class RunLease(DashboardRunStore owner, DashboardRunDescriptor run, FileLock runLock) : IDisposable
    {
        private FileLock? _runLock = runLock;
 
        public void Dispose()
        {
            lock (owner._runStateLock)
            {
                var runLock = Interlocked.Exchange(ref _runLock, null);
                if (runLock is not null)
                {
                    try
                    {
                        runLock.Dispose();
                    }
                    finally
                    {
                        run.IsLeased = false;
                    }
                }
            }
 
            GC.SuppressFinalize(this);
        }
    }
 
    private sealed record DashboardRunMetadata
    {
        public required int SchemaVersion { get; init; }
        public required string RunId { get; init; }
        public required DateTimeOffset StartedAtUtc { get; init; }
        public DateTimeOffset? EndedAtUtc { get; init; }
        public bool CleanShutdown { get; init; }
        public string? ApplicationName { get; init; }
        public required string DatabaseFileName { get; init; }
        public bool IsPinned { get; init; }
    }
}
 
/// <summary>
/// Describes a dashboard run available for selection.
/// </summary>
/// <param name="RunId">The unique identifier for the dashboard run.</param>
/// <param name="SchemaVersion">The dashboard database schema version used by the run.</param>
/// <param name="StartedAtUtc">The time at which the run started.</param>
/// <param name="EndedAtUtc">The time at which the run ended, or <see langword="null"/> when it has not ended.</param>
/// <param name="CleanShutdown">A value indicating whether the run shut down cleanly.</param>
/// <param name="ApplicationName">The application name associated with the run.</param>
/// <param name="DatabasePath">The path to the dashboard database for the run.</param>
/// <param name="IsCurrent">A value indicating whether this is the current dashboard run.</param>
public sealed record DashboardRunDescriptor(
    string RunId,
    int SchemaVersion,
    DateTimeOffset StartedAtUtc,
    DateTimeOffset? EndedAtUtc,
    bool CleanShutdown,
    string? ApplicationName,
    string DatabasePath,
    bool IsCurrent)
{
    /// <summary>
    /// Gets or sets a value indicating whether the dashboard run was pruned.
    /// </summary>
    public bool IsPruned { get; set; }
 
    internal bool IsSelectable { get; set; } = true;
 
    /// <summary>
    /// Gets a value indicating whether the dashboard run is pinned.
    /// </summary>
    public bool IsPinned { get; internal set; }
 
    internal bool IsLeased { get; set; }
}