| File: ServiceClient\DashboardSqliteDatabase.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 Dapper; using System.Data; using System.Diagnostics; using System.Globalization; using Aspire.Dashboard.Utils; using Microsoft.Data.Sqlite; namespace Aspire.Dashboard.ServiceClient; /// <summary> /// Creates consistently configured connections to a dashboard run database. /// </summary> public sealed class DashboardSqliteDatabase : IDisposable { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; internal const int SchemaVersion = 17; private static readonly Lazy<IReadOnlyList<string>> s_schemaScripts = new(LoadSchemaScripts); private readonly string _connectionString; private readonly ActivitySource _activitySource = new(TracingSqliteConnection.ActivitySourceName); private bool _schemaInitialized; /// <summary> /// Initializes a new instance of the <see cref="DashboardSqliteDatabase"/> class. /// </summary> /// <param name="databasePath">The path to the dashboard database.</param> /// <param name="readOnly">A value indicating whether the database is opened for read-only access.</param> /// <param name="pooling">A value indicating whether SQLite connection pooling is enabled.</param> public DashboardSqliteDatabase(string databasePath, bool readOnly = false, bool pooling = true) { ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); DatabasePath = Path.GetFullPath(databasePath); IsReadOnly = readOnly; if (!readOnly) { Directory.CreateDirectory(Path.GetDirectoryName(DatabasePath)!); } _connectionString = new SqliteConnectionStringBuilder { DataSource = DatabasePath, Mode = readOnly ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate, Pooling = pooling, ForeignKeys = true, DefaultTimeout = 5 }.ToString(); } /// <summary> /// Gets the full path to the dashboard database. /// </summary> public string DatabasePath { get; } /// <summary> /// Gets a value indicating whether the database is opened for read-only access. /// </summary> public bool IsReadOnly { get; } internal ActivitySource ActivitySource => _activitySource; /// <summary> /// Gets the lock that serializes writes to this database. /// </summary> internal AsyncLock WriteLock { get; } = new(); /// <summary> /// Determines whether a dashboard database uses the current schema version. /// </summary> /// <param name="databasePath">The path to the dashboard database.</param> /// <returns><see langword="true"/> when the database is compatible; otherwise, <see langword="false"/>.</returns> /// <exception cref="SqliteException">The database schema version could not be read.</exception> public static bool IsCompatible(string databasePath) { if (!File.Exists(databasePath)) { return false; } using var database = new DashboardSqliteDatabase(databasePath, readOnly: true, pooling: false); using var connection = database.OpenConnection(); return ValidateSchemaVersion(connection, transaction: null, SchemaVersion); } internal TracingSqliteConnection OpenConnection() { var connection = new TracingSqliteConnection(_connectionString, DatabasePath, _activitySource); connection.Open(); // synchronous is scoped to the native connection and is not stored in the database. A pooled native // connection retains its value, but Microsoft.Data.Sqlite doesn't expose whether Open created a new // native connection or leased one from the pool, so apply it after every logical open. NORMAL avoids // syncing the WAL after every commit while preserving database consistency, although a power loss can // discard the most recent transactions. // See https://sqlite.org/pragma.html#pragma_synchronous. connection.ConfigureSynchronousNormal(); return connection; } internal bool ValidateSchemaVersion(int metadataSchemaVersion) { using var connection = OpenConnection(); return ValidateSchemaVersion(connection, transaction: null, metadataSchemaVersion); } /// <summary> /// Clears pooled SQLite connections associated with this database. /// </summary> public void ClearPool() { using var connection = new SqliteConnection(_connectionString); SqliteConnection.ClearPool(connection); } public void Dispose() => _activitySource.Dispose(); /// <summary> /// Initializes the dashboard database schema when it has not already been initialized. /// </summary> /// <param name="cancellationToken">The token to monitor for cancellation requests.</param> public async Task InitializeSchemaAsync(CancellationToken cancellationToken) { EnsureWritable("Historical dashboard data is read-only."); using (await WriteLock.LockAsync(cancellationToken).ConfigureAwait(false)) { if (_schemaInitialized) { return; } using var connection = OpenConnection(); // Unlike synchronous, WAL journal mode is stored in the database and persists across connections // and process restarts, so it only needs to be set during database initialization rather than on // every open. WAL appends writes sequentially and allows readers to continue while a writer commits. // See https://sqlite.org/pragma.html#pragma_journal_mode. connection.Execute("PRAGMA journal_mode = WAL;"); var schemaTableExists = connection.QuerySingle<long>(""" SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'dashboard_schema'; """) != 0; if (schemaTableExists) { var existingSchemaVersion = GetSchemaVersion(connection, transaction: null); if (existingSchemaVersion != SchemaVersion) { throw new InvalidOperationException($"The dashboard database schema version {FormatSchemaVersion(existingSchemaVersion)} does not match the expected version {SchemaVersion}."); } } using var transaction = connection.BeginTransaction(); foreach (var script in s_schemaScripts.Value) { connection.Execute(script, new { SchemaVersion }, transaction); } var initializedSchemaVersion = GetSchemaVersion(connection, transaction); if (initializedSchemaVersion != SchemaVersion) { throw new InvalidOperationException($"The dashboard database schema was initialized to version {FormatSchemaVersion(initializedSchemaVersion)} instead of the expected version {SchemaVersion}."); } transaction.Commit(); _schemaInitialized = true; } } /// <summary> /// Throws an exception with the specified message when the database is read-only. /// </summary> /// <param name="message">The exception message used when the database is read-only.</param> /// <exception cref="InvalidOperationException">The database is read-only.</exception> public void EnsureWritable(string message) { if (IsReadOnly) { throw new InvalidOperationException(message); } } private static IReadOnlyList<string> LoadSchemaScripts() { var assembly = typeof(DashboardSqliteDatabase).Assembly; // Numeric filename prefixes define execution order because later schema domains reference tables created by earlier scripts. var resourceNames = assembly.GetManifestResourceNames() .Where(name => name.StartsWith(SchemaResourcePrefix, StringComparison.Ordinal) && name.EndsWith(".sql", StringComparison.Ordinal)) .Order(StringComparer.Ordinal) .ToArray(); if (resourceNames.Length == 0) { throw new InvalidOperationException("No embedded dashboard database schema scripts were found."); } var scripts = new List<string>(resourceNames.Length); foreach (var resourceName in resourceNames) { using var stream = assembly.GetManifestResourceStream(resourceName) ?? throw new InvalidOperationException($"Embedded dashboard database schema script '{resourceName}' was not found."); using var reader = new StreamReader(stream); scripts.Add(reader.ReadToEnd()); } return scripts; } private static bool ValidateSchemaVersion(SqliteConnection connection, IDbTransaction? transaction, int expectedVersion) { // Opening the database and setting WAL creates a valid SQLite file before the schema transaction // commits, so an interrupted first initialization leaves a file with no dashboard_schema table. // Querying it directly throws "no such table: dashboard_schema", and because Resume only replaces // the database when this returns false, every later start would keep crashing. // // Probe sqlite_master first so a missing schema table reports "not compatible" while genuine IO, // locking, and corruption failures still surface as exceptions. // See https://www.sqlite.org/schematab.html var schemaTableCount = connection.QuerySingle<long>(""" SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'dashboard_schema'; """, transaction: transaction); if (schemaTableCount == 0) { return false; } return GetSchemaVersion(connection, transaction) == expectedVersion; } private static int? GetSchemaVersion(SqliteConnection connection, IDbTransaction? transaction) { return connection.QuerySingleOrDefault<int?>(""" SELECT CASE WHEN COUNT(*) = 1 THEN MAX(version) ELSE NULL END FROM dashboard_schema; """, transaction: transaction); } private static string FormatSchemaVersion(int? version) => version?.ToString(CultureInfo.InvariantCulture) ?? "unknown"; }