Add SQLite runtime and schema details to the index store

This commit is contained in:
Thorsten Sommer 2026-09-17 10:49:03 +02:00
parent be6754cf58
commit 1e712f5f83
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
3 changed files with 318 additions and 6 deletions

View File

@ -20,9 +20,20 @@ public sealed class NoIndexStoreClient(string name, string? unavailableReason, D
if (!string.IsNullOrWhiteSpace(unavailableReason))
yield return (TB("Reason"), unavailableReason);
//
// Say which native library this process bound to even though the database itself is out of
// reach. When SQLite cannot be loaded on a platform at all, this client is exactly what the
// user sees, so this is the one place where those details matter most.
//
yield return (TB("Native library"), OrUnknown(SqliteRuntimeInfo.GetNativeLibraryName()));
yield return (TB("Native library path"), OrUnknown(SqliteRuntimeInfo.GetNativeLibraryPath()));
yield return (TB("Process architecture"), OrUnknown(SqliteRuntimeInfo.GetProcessArchitecture()));
await Task.CompletedTask;
}
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
public override Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token) => Task.FromResult(new DataSourceEmbeddingManifest());
public override Task<DataSourceIndexState?> GetDataSourceStateAsync(string dataSourceId, CancellationToken token) => Task.FromResult<DataSourceIndexState?>(null);

View File

@ -1,3 +1,4 @@
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
@ -60,16 +61,37 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
await using var context = this.CreateContext();
//
// Read everything before yielding the first line: this is an iterator, and a try/catch
// cannot wrap a yield return. Each probe therefore catches its own failure and answers
// with an empty string, which shows up as "unknown" below. Without that, a single failing
// PRAGMA would throw out of here and the information page would replace the entire block
// with the fallback client.
//
var snapshot = await this.ReadDisplaySnapshotAsync();
yield return (TB("Reported version"), version);
yield return (TB("Library source ID"), OrUnknown(snapshot.LibrarySourceId));
yield return (TB("Native library"), OrUnknown(SqliteRuntimeInfo.GetNativeLibraryName()));
yield return (TB("Native library path"), OrNotDetermined(SqliteRuntimeInfo.GetNativeLibraryPath()));
yield return (TB("Wrapper version"), OrUnknown(SqliteRuntimeInfo.GetWrapperVersion()));
yield return (TB("Process architecture"), OrUnknown(SqliteRuntimeInfo.GetProcessArchitecture()));
// Only worth a line when the process runs on a foreign architecture, Rosetta above all:
var systemArchitecture = SqliteRuntimeInfo.GetSystemArchitecture();
if (!string.IsNullOrWhiteSpace(systemArchitecture))
yield return (TB("System architecture"), systemArchitecture);
yield return (TB("Full-text search (FTS5)"), OrUnknown(snapshot.FullTextSearch));
yield return (TB("Database path"), this.databasePath);
yield return (TB("Journal mode"), OrUnknown(snapshot.JournalMode));
yield return (TB("Schema version"), OrUnknown(snapshot.SchemaVersion));
yield return (TB("Database tables"), OrUnknown(snapshot.TableCount));
yield return (TB("Storage size"), this.GetStorageSize());
yield return (TB("Indexed data sources"), (await context.DataSources.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Indexed files"), (await context.EmbeddedFiles.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
var searchChunks = await this.GetTotalChunkCountAsync(CancellationToken.None);
yield return (TB("Search chunks"), searchChunks?.ToString(CultureInfo.InvariantCulture) ?? TB("unknown"));
yield return (TB("Permanently skipped files"), (await context.PermanentIndexingFailures.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Indexed data sources"), OrUnknown(snapshot.DataSourceCount));
yield return (TB("Indexed files"), OrUnknown(snapshot.FileCount));
yield return (TB("Search chunks"), OrUnknown(snapshot.ChunkCount));
yield return (TB("Permanently skipped files"), OrUnknown(snapshot.FailureCount));
}
public override async Task<DataSourceIndexState?> GetDataSourceStateAsync(string dataSourceId, CancellationToken token)
@ -376,6 +398,150 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
{
}
/// <summary>
/// Everything the display info reads out of the database in one go.
/// </summary>
/// <remarks>
/// Every property is empty when its probe could not answer. The caller turns that into "unknown".
/// </remarks>
private sealed record DisplaySnapshot
{
public string LibrarySourceId { get; init; } = string.Empty;
public string FullTextSearch { get; init; } = string.Empty;
public string JournalMode { get; init; } = string.Empty;
public string SchemaVersion { get; init; } = string.Empty;
public string TableCount { get; init; } = string.Empty;
public string DataSourceCount { get; init; } = string.Empty;
public string FileCount { get; init; } = string.Empty;
public string ChunkCount { get; init; } = string.Empty;
public string FailureCount { get; init; } = string.Empty;
}
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
private static string OrNotDetermined(string value) => string.IsNullOrWhiteSpace(value) ? TB("not determined") : value;
private async Task<DisplaySnapshot> ReadDisplaySnapshotAsync()
{
var token = CancellationToken.None;
try
{
await using var context = this.CreateContext();
return new DisplaySnapshot
{
LibrarySourceId = await QueryScalarTextAsync(context, "SELECT sqlite_source_id()", token),
FullTextSearch = await GetFullTextSearchStateAsync(context, token),
JournalMode = (await QueryScalarTextAsync(context, "PRAGMA journal_mode;", token)).ToUpperInvariant(),
SchemaVersion = await GetSchemaVersionAsync(context, token),
TableCount = await GetTableCountAsync(context, token),
DataSourceCount = await FormatCountAsync(context.DataSources, token),
FileCount = await FormatCountAsync(context.EmbeddedFiles, token),
ChunkCount = (await this.GetTotalChunkCountAsync(token))?.ToString("N0", I18N.I.Culture) ?? string.Empty,
FailureCount = await FormatCountAsync(context.PermanentIndexingFailures, token),
};
}
catch (Exception exception)
{
//
// Opening the database failed altogether. The runtime details the caller shows next to
// these values still say which library was loaded and for which architecture, which is
// what a support case needs most in exactly this situation. So hand back an empty
// snapshot instead of letting the whole block fall back.
//
this.Logger?.LogWarning(exception, "Failed to read the display details of the local RAG index.");
return new DisplaySnapshot();
}
}
private static async Task<string> QueryScalarTextAsync(IndexStoreDbContext context, string sql, CancellationToken token)
{
try
{
//
// Go through the raw connection rather than through SqlQueryRaw: that one expects a
// column named "Value" and wraps the statement, neither of which works for a PRAGMA.
//
var connection = context.Database.GetDbConnection();
if (connection.State is not ConnectionState.Open)
await connection.OpenAsync(token);
await using var command = connection.CreateCommand();
command.CommandText = sql;
var result = await command.ExecuteScalarAsync(token);
return result?.ToString() ?? string.Empty;
}
catch
{
return string.Empty;
}
}
private static async Task<string> GetFullTextSearchStateAsync(IndexStoreDbContext context, CancellationToken token)
{
var compiledIn = await QueryScalarTextAsync(context, "SELECT sqlite_compileoption_used('ENABLE_FTS5')", token);
return compiledIn switch
{
"1" => TB("available"),
"0" => TB("not available"),
_ => string.Empty
};
}
private static async Task<string> GetTableCountAsync(IndexStoreDbContext context, CancellationToken token)
{
//
// Counts the migration history, the FTS5 virtual table and its shadow tables as well. That
// is the point: a missing shadow table is a finding, not noise.
//
var tables = await QueryScalarTextAsync(context, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", token);
return int.TryParse(tables, NumberStyles.Integer, CultureInfo.InvariantCulture, out var tableCount) ? tableCount.ToString("N0", I18N.I.Culture) : string.Empty;
}
private static async Task<string> GetSchemaVersionAsync(IndexStoreDbContext context, CancellationToken token)
{
try
{
//
// Reading the applied migrations only touches the history table, no assembly scan. The
// pending ones do scan, but the schema migrator walks that same path on every start, so
// the DynamicDependency attributes over there already keep the migration types alive.
//
var appliedMigrations = (await context.Database.GetAppliedMigrationsAsync(token)).ToList();
if (appliedMigrations.Count == 0)
return TB("no migration applied");
var pendingMigrations = (await context.Database.GetPendingMigrationsAsync(token)).ToList();
return pendingMigrations.Count == 0
? string.Format(I18N.I.Culture, TB("{0} ({1} applied)"), appliedMigrations[^1], appliedMigrations.Count)
: string.Format(I18N.I.Culture, TB("{0} ({1} applied, {2} pending)"), appliedMigrations[^1], appliedMigrations.Count, pendingMigrations.Count);
}
catch
{
return string.Empty;
}
}
private static async Task<string> FormatCountAsync<T>(IQueryable<T> query, CancellationToken token) where T : class
{
try
{
return (await query.CountAsync(token)).ToString("N0", I18N.I.Culture);
}
catch
{
return string.Empty;
}
}
private async Task InitializeAsync(CancellationToken token)
{
await using var context = this.CreateContext();

View File

@ -0,0 +1,135 @@
using System.Runtime.InteropServices;
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// What the running process can tell about the SQLite library it has loaded.
/// </summary>
/// <remarks>
/// These are methods instead of static fields on purpose: SQLitePCL.Batteries_V2.Init() runs when the
/// index store client is created, and a type initializer could well run before that. Every member
/// answers with an empty string when it cannot tell, so a single unavailable detail never costs the
/// caller the rest of them.
/// </remarks>
internal static class SqliteRuntimeInfo
{
/// <summary>
/// The name of the native library SQLitePCLRaw has bound to.
/// </summary>
/// <remarks>
/// We ship our own build through the bundle_e_sqlite3 package, so this reads e_sqlite3 on every
/// platform. Anything else means the process bound to a different library than we shipped, which
/// is exactly the kind of thing a support case needs to show.
/// </remarks>
public static string GetNativeLibraryName()
{
try
{
return SQLitePCL.raw.GetNativeLibraryName();
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Where the native library sits on disk.
/// </summary>
/// <remarks>
/// This probes the file system instead of enumerating the loaded modules: Process.Modules throws
/// on macOS, and NativeLibrary hands out no path at all. For our single-file builds, the base
/// directory is where the runtime extracts the native assets to, so that is the first candidate.
/// </remarks>
public static string GetNativeLibraryPath()
{
try
{
var libraryName = GetNativeLibraryName();
if (string.IsNullOrWhiteSpace(libraryName))
return string.Empty;
var fileName = GetNativeFileName(libraryName);
var baseDirectory = AppContext.BaseDirectory;
string[] candidates =
[
Path.Combine(baseDirectory, fileName),
Path.Combine(baseDirectory, "runtimes", RuntimeInformation.RuntimeIdentifier, "native", fileName),
];
foreach (var candidate in candidates)
if (File.Exists(candidate))
return candidate;
return string.Empty;
}
catch
{
return string.Empty;
}
}
/// <summary>
/// The version of the managed SQLitePCLRaw wrapper, which is a different thing than the SQLite version.
/// </summary>
public static string GetWrapperVersion()
{
try
{
// Read the assembly name rather than an attribute: reflecting over members would not
// survive trimming, the name does.
var wrapperVersion = typeof(SQLitePCL.raw).Assembly.GetName().Version;
return wrapperVersion is null ? string.Empty : $"SQLitePCLRaw.core {wrapperVersion.ToString(3)}";
}
catch
{
return string.Empty;
}
}
/// <summary>
/// The architecture this process runs as, together with the runtime identifier it was built for.
/// </summary>
public static string GetProcessArchitecture()
{
try
{
return $"{RuntimeInformation.ProcessArchitecture} ({RuntimeInformation.RuntimeIdentifier})";
}
catch
{
return string.Empty;
}
}
/// <summary>
/// The architecture of the machine, but only when it differs from the one of the process.
/// </summary>
/// <remarks>
/// A difference means the process runs through an emulation layer, Rosetta above all. That is a
/// classic reason for a native library failing to load, so it earns its own line when it happens
/// and stays out of the way when it does not.
/// </remarks>
public static string GetSystemArchitecture()
{
try
{
return RuntimeInformation.OSArchitecture == RuntimeInformation.ProcessArchitecture ? string.Empty : RuntimeInformation.OSArchitecture.ToString();
}
catch
{
return string.Empty;
}
}
private static string GetNativeFileName(string libraryName)
{
if (OperatingSystem.IsWindows())
return $"{libraryName}.dll";
if (OperatingSystem.IsMacOS())
return $"lib{libraryName}.dylib";
return $"lib{libraryName}.so";
}
}