diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index a4205982..0828bcbc 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -6157,6 +6157,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840227993"] = "Used .NET runtim -- Explanation UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation" +-- checking availability +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2855535668"] = "checking availability" + -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software." @@ -6265,6 +6268,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "This is a library -- Used .NET SDK UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Used .NET SDK" +-- starting +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T594602073"] = "starting" + -- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated." @@ -6901,6 +6907,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = " -- Reason UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Reason" +-- Starting +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1233211769"] = "Starting" + -- Unavailable UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T3662391977"] = "Unavailable" diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index 10a6b614..1070e234 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -29,7 +29,7 @@ public partial class Information : MSGComponentBase private ISnackbar Snackbar { get; init; } = null!; [Inject] - private DatabaseClient DatabaseClient { get; init; } = null!; + private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!; private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly(); private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute()!; @@ -62,9 +62,22 @@ public partial class Information : MSGComponentBase private string VersionPdfium => $"{T("Used PDFium version")}: v{META_DATA_LIBRARIES.PdfiumVersion}"; - private string VersionDatabase => this.DatabaseClient.IsAvailable - ? $"{T("Database version")}: {this.DatabaseClient.Name} v{META_DATA_DATABASES.DatabaseVersion}" - : $"{T("Database")}: {this.DatabaseClient.Name} - {T("not available")}"; + private string VersionDatabase + { + get + { + var databaseClient = this.databaseClient; + if (databaseClient is null) + return $"{T("Database")}: {T("checking availability")}"; + + return databaseClient.Status switch + { + DatabaseClientStatus.AVAILABLE => $"{T("Database version")}: {databaseClient.Name} v{META_DATA_DATABASES.DatabaseVersion}", + DatabaseClientStatus.STARTING => $"{T("Database")}: {databaseClient.Name} - {T("starting")}", + _ => $"{T("Database")}: {databaseClient.Name} - {T("not available")}" + }; + } + } private string versionPandoc = TB("Determine Pandoc version, please wait..."); private PandocInstallation pandocInstallation; @@ -89,6 +102,8 @@ public partial class Information : MSGComponentBase private sealed record MandatoryInfoPanelData(string HeaderText, string PluginName, DataMandatoryInfo Info, DataMandatoryInfoAcceptance? Acceptance); private readonly List databaseDisplayInfo = new(); + private DatabaseClient? databaseClient; + private CancellationTokenSource? databaseRefreshCancellationTokenSource; private bool HasAnyActiveEnvironment => this.enterpriseEnvironments.Any(e => e.IsActive); @@ -134,10 +149,9 @@ public partial class Information : MSGComponentBase this.osUserName = await this.RustService.ReadUserName(); this.logPaths = await this.RustService.GetLogPaths(); - await foreach (var (label, value) in this.DatabaseClient.GetDisplayInfo()) - { - this.databaseDisplayInfo.Add(new DatabaseDisplayInfo(label, value)); - } + await this.RefreshDatabaseInfo(CancellationToken.None); + if (this.databaseClient?.Status is DatabaseClientStatus.STARTING) + this.StartShortDatabaseRefreshLoop(); // Determine the Pandoc version may take some time, so we start it here // without waiting for the result: @@ -241,6 +255,69 @@ public partial class Information : MSGComponentBase this.showDatabaseDetails = !this.showDatabaseDetails; } + private async Task RefreshDatabaseInfo(CancellationToken cancellationToken) + { + var refreshedClient = await this.DatabaseClientProvider.RefreshClientAsync(DatabaseRole.VECTOR_STORE, cancellationToken); + this.databaseClient = refreshedClient; + this.databaseDisplayInfo.Clear(); + + try + { + await foreach (var (label, value) in refreshedClient.GetDisplayInfo().WithCancellation(cancellationToken)) + { + this.databaseDisplayInfo.Add(new DatabaseDisplayInfo(label, value)); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + this.databaseClient = new NoDatabaseClient(refreshedClient.Name, e.Message, DatabaseClientStatus.STARTING); + await foreach (var (label, value) in this.databaseClient.GetDisplayInfo().WithCancellation(cancellationToken)) + { + this.databaseDisplayInfo.Add(new DatabaseDisplayInfo(label, value)); + } + } + } + + private void StartShortDatabaseRefreshLoop() + { + this.databaseRefreshCancellationTokenSource?.Cancel(); + this.databaseRefreshCancellationTokenSource?.Dispose(); + this.databaseRefreshCancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = this.databaseRefreshCancellationTokenSource.Token; + + _ = Task.Run(async () => + { + const int MAX_TRIES = 12; + for (var attempt = 0; attempt < MAX_TRIES; attempt++) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + await this.InvokeAsync(async () => + { + await this.RefreshDatabaseInfo(cancellationToken); + this.StateHasChanged(); + }); + + if (this.databaseClient?.Status is not DatabaseClientStatus.STARTING) + return; + } + catch (OperationCanceledException) + { + return; + } + catch + { + return; + } + } + }, cancellationToken); + } + private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId) { return this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId == configurationId) @@ -253,6 +330,13 @@ public partial class Information : MSGComponentBase return plugin.ManagedConfigurationId == configurationId && plugin.Id != configurationId; } + protected override void DisposeResources() + { + this.databaseRefreshCancellationTokenSource?.Cancel(); + this.databaseRefreshCancellationTokenSource?.Dispose(); + base.DisposeResources(); + } + private async Task CopyStartupLogPath() { await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath); diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index f2b9b06c..996c5c43 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -2,7 +2,6 @@ using AIStudio.Agents; using AIStudio.Agents.AssistantAudit; using AIStudio.Settings; using AIStudio.Tools.Databases; -using AIStudio.Tools.Databases.Qdrant; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.Services; @@ -28,7 +27,7 @@ internal sealed class Program public static string API_TOKEN = null!; public static IServiceProvider SERVICE_PROVIDER = null!; public static ILoggerFactory LOGGER_FACTORY = null!; - public static DatabaseClient DATABASE_CLIENT = null!; + public static DatabaseClientProvider DATABASE_CLIENT_PROVIDER = null!; public static async Task Main() { @@ -87,48 +86,6 @@ internal sealed class Program return; } - var qdrantInfo = await rust.GetQdrantInfo(); - DatabaseClient databaseClient; - if (!qdrantInfo.IsAvailable) - { - Console.WriteLine($"Warning: Qdrant is not available. Starting without vector database. Reason: '{qdrantInfo.UnavailableReason ?? "unknown"}'."); - databaseClient = new NoDatabaseClient("Qdrant", qdrantInfo.UnavailableReason); - } - else - { - if (qdrantInfo.Path == string.Empty) - { - Console.WriteLine("Error: Failed to get the Qdrant path from Rust."); - return; - } - - if (qdrantInfo.PortHttp == 0) - { - Console.WriteLine("Error: Failed to get the Qdrant HTTP port from Rust."); - return; - } - - if (qdrantInfo.PortGrpc == 0) - { - Console.WriteLine("Error: Failed to get the Qdrant gRPC port from Rust."); - return; - } - - if (qdrantInfo.Fingerprint == string.Empty) - { - Console.WriteLine("Error: Failed to get the Qdrant fingerprint from Rust."); - return; - } - - if (qdrantInfo.ApiToken == string.Empty) - { - Console.WriteLine("Error: Failed to get the Qdrant API token from Rust."); - return; - } - - databaseClient = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken); - } - var builder = WebApplication.CreateBuilder(); builder.WebHost.ConfigureKestrel(kestrelServerOptions => { @@ -183,7 +140,7 @@ internal sealed class Program builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); - builder.Services.AddSingleton(databaseClient); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -242,10 +199,7 @@ internal sealed class Program RUST_SERVICE = rust; ENCRYPTION = encryption; - - var databaseLogger = app.Services.GetRequiredService>(); - databaseClient.SetLogger(databaseLogger); - DATABASE_CLIENT = databaseClient; + DATABASE_CLIENT_PROVIDER = app.Services.GetRequiredService(); programLogger.LogInformation("Initialize internal file system."); app.Use(Redirect.HandlerContentAsync); @@ -283,7 +237,7 @@ internal sealed class Program await serverTask; RUST_SERVICE.Dispose(); - DATABASE_CLIENT.Dispose(); + DATABASE_CLIENT_PROVIDER.Dispose(); PluginFactory.Dispose(); programLogger.LogInformation("The AI Studio server was stopped."); } diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs index b80cba94..2fb9fced 100644 --- a/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs @@ -4,7 +4,11 @@ public abstract class DatabaseClient(string name, string path) { public string Name => name; - public virtual bool IsAvailable => true; + public virtual string CacheKey => name; + + public virtual DatabaseClientStatus Status => DatabaseClientStatus.AVAILABLE; + + public bool IsAvailable => this.Status is DatabaseClientStatus.AVAILABLE; private string Path => path; diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs new file mode 100644 index 00000000..4296ec53 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs @@ -0,0 +1,180 @@ +using AIStudio.Tools.Databases.Qdrant; +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +namespace AIStudio.Tools.Databases; + +public sealed class DatabaseClientProvider(RustService rustService, ILoggerFactory loggerFactory) : IDisposable +{ + private readonly Dictionary clients = new(); + private readonly Dictionary locks = new(); + private readonly Lock locksLock = new(); + private readonly ILogger logger = loggerFactory.CreateLogger(); + private readonly ILogger databaseClientLogger = loggerFactory.CreateLogger(); + + public async Task GetClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken = default) + { + var databaseLock = this.GetLock(databaseRole); + await databaseLock.WaitAsync(cancellationToken); + try + { + if (this.clients.TryGetValue(databaseRole, out var cachedClient) && cachedClient.IsAvailable) + return cachedClient; + + var client = await this.CreateClientAsync(databaseRole, cancellationToken); + return this.CacheIfAvailable(databaseRole, client); + } + finally + { + databaseLock.Release(); + } + } + + public async Task RefreshClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken = default) + { + var databaseLock = this.GetLock(databaseRole); + await databaseLock.WaitAsync(cancellationToken); + try + { + var client = await this.CreateClientAsync(databaseRole, cancellationToken); + return this.CacheIfAvailable(databaseRole, client); + } + finally + { + databaseLock.Release(); + } + } + + private DatabaseClient CacheIfAvailable(DatabaseRole databaseRole, DatabaseClient client) + { + if (!client.IsAvailable) + return client; + + if (this.clients.TryGetValue(databaseRole, out var cachedClient)) + { + if (IsSameClient(cachedClient, client)) + { + client.Dispose(); + return cachedClient; + } + + cachedClient.Dispose(); + } + + this.clients[databaseRole] = client; + return client; + } + + private SemaphoreSlim GetLock(DatabaseRole databaseRole) + { + lock (this.locksLock) + { + if (this.locks.TryGetValue(databaseRole, out var databaseLock)) + return databaseLock; + + databaseLock = new SemaphoreSlim(1, 1); + this.locks[databaseRole] = databaseLock; + return databaseLock; + } + } + + private async Task CreateClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken) => databaseRole switch + { + DatabaseRole.VECTOR_STORE => await this.CreateQdrantClientAsync(cancellationToken), + _ => new NoDatabaseClient(databaseRole.ToString(), "The requested database role is not supported.") + }; + + private async Task CreateQdrantClientAsync(CancellationToken cancellationToken) + { + var qdrantInfo = await rustService.GetQdrantInfo(cancellationToken); + if (qdrantInfo.Status is QdrantStatus.STARTING) + { + return this.CreateNoDatabaseClient( + "Qdrant", + "Qdrant is starting. Details will appear shortly.", + DatabaseClientStatus.STARTING); + } + + if (!qdrantInfo.IsAvailable || qdrantInfo.Status is QdrantStatus.UNAVAILABLE) + { + var reason = qdrantInfo.UnavailableReason ?? "unknown"; + this.logger.LogWarning("Qdrant is not available. Starting without vector database. Reason: '{Reason}'.", reason); + return this.CreateNoDatabaseClient("Qdrant", qdrantInfo.UnavailableReason, DatabaseClientStatus.UNAVAILABLE); + } + + if (!HasValidQdrantConnectionInfo(qdrantInfo, out var invalidReason)) + return this.CreateNoDatabaseClient("Qdrant", invalidReason, DatabaseClientStatus.UNAVAILABLE); + + var client = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken); + client.SetLogger(this.databaseClientLogger); + + try + { + await client.CheckAvailabilityAsync(); + return client; + } + catch (Exception e) + { + client.Dispose(); + this.logger.LogWarning(e, "Qdrant reported as available by Rust, but the health check failed."); + return this.CreateNoDatabaseClient("Qdrant", e.Message, DatabaseClientStatus.STARTING); + } + } + + private static bool HasValidQdrantConnectionInfo(QdrantInfo qdrantInfo, out string invalidReason) + { + if (qdrantInfo.Path == string.Empty) + { + invalidReason = "Failed to get the Qdrant path from Rust."; + return false; + } + + if (qdrantInfo.PortHttp == 0) + { + invalidReason = "Failed to get the Qdrant HTTP port from Rust."; + return false; + } + + if (qdrantInfo.PortGrpc == 0) + { + invalidReason = "Failed to get the Qdrant gRPC port from Rust."; + return false; + } + + if (qdrantInfo.Fingerprint == string.Empty) + { + invalidReason = "Failed to get the Qdrant fingerprint from Rust."; + return false; + } + + if (qdrantInfo.ApiToken == string.Empty) + { + invalidReason = "Failed to get the Qdrant API token from Rust."; + return false; + } + + invalidReason = string.Empty; + return true; + } + + private NoDatabaseClient CreateNoDatabaseClient(string name, string? unavailableReason, DatabaseClientStatus status) + { + var client = new NoDatabaseClient(name, unavailableReason, status); + client.SetLogger(this.databaseClientLogger); + return client; + } + + private static bool IsSameClient(DatabaseClient left, DatabaseClient right) => + left.IsAvailable + && right.IsAvailable + && left.CacheKey == right.CacheKey; + + public void Dispose() + { + foreach (var client in this.clients.Values) + client.Dispose(); + + foreach (var databaseLock in this.locks.Values) + databaseLock.Dispose(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClientStatus.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClientStatus.cs new file mode 100644 index 00000000..c9084353 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClientStatus.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.Databases; + +public enum DatabaseClientStatus +{ + STARTING, + AVAILABLE, + UNAVAILABLE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseRole.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseRole.cs new file mode 100644 index 00000000..d4b5be3c --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseRole.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Tools.Databases; + +public enum DatabaseRole +{ + VECTOR_STORE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/NoDatabaseClient.cs b/app/MindWork AI Studio/Tools/Databases/NoDatabaseClient.cs index 7b3b0cd4..cd778f7b 100644 --- a/app/MindWork AI Studio/Tools/Databases/NoDatabaseClient.cs +++ b/app/MindWork AI Studio/Tools/Databases/NoDatabaseClient.cs @@ -2,15 +2,19 @@ using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.Databases; -public sealed class NoDatabaseClient(string name, string? unavailableReason) : DatabaseClient(name, string.Empty) +public sealed class NoDatabaseClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : DatabaseClient(name, string.Empty) { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoDatabaseClient).Namespace, nameof(NoDatabaseClient)); - public override bool IsAvailable => false; + public override DatabaseClientStatus Status => status; public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() { - yield return (TB("Status"), TB("Unavailable")); + yield return (TB("Status"), status switch + { + DatabaseClientStatus.STARTING => TB("Starting"), + _ => TB("Unavailable") + }); if (!string.IsNullOrWhiteSpace(unavailableReason)) yield return (TB("Reason"), unavailableReason); diff --git a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs index 60a13419..b3a09e68 100644 --- a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs +++ b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs @@ -26,6 +26,8 @@ public class QdrantClientImplementation : DatabaseClient this.ApiToken = apiToken; this.GrpcClient = this.CreateQdrantClient(); } + + public override string CacheKey => $"{this.Name}:{this.HttpPort}:{this.GrpcPort}:{this.Fingerprint}"; private const string IP_ADDRESS = "localhost"; @@ -47,6 +49,11 @@ public class QdrantClientImplementation : DatabaseClient return $"v{operation.Version}"; } + public async Task CheckAvailabilityAsync() + { + await this.GrpcClient.HealthAsync(); + } + private async Task GetCollectionsAmount() { var operation = await this.GrpcClient.ListCollectionsAsync(); diff --git a/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs b/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs index 5315eca7..30044596 100644 --- a/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs +++ b/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs @@ -5,6 +5,8 @@ /// public readonly record struct QdrantInfo { + public QdrantStatus Status { get; init; } + public bool IsAvailable { get; init; } public string? UnavailableReason { get; init; } diff --git a/app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs b/app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs new file mode 100644 index 00000000..10d6246a --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.Rust; + +public enum QdrantStatus +{ + STARTING, + AVAILABLE, + UNAVAILABLE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs index a43f6c61..3efc8050 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs @@ -4,13 +4,27 @@ namespace AIStudio.Tools.Services; public sealed partial class RustService { - public async Task GetQdrantInfo() + public async Task GetQdrantInfo(CancellationToken cancellationToken = default) { try { - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - var response = await this.http.GetFromJsonAsync("/system/qdrant/info", this.jsonRustSerializerOptions, cts.Token); - return response; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(45)); + + return await this.http.GetFromJsonAsync("/system/qdrant/info", this.jsonRustSerializerOptions, cts.Token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if(this.logger is not null) + this.logger.LogWarning("Fetching Qdrant info from Rust service was cancelled by caller."); + else + Console.WriteLine("Fetching Qdrant info from Rust service was cancelled by caller."); + + return new QdrantInfo + { + Status = QdrantStatus.UNAVAILABLE, + UnavailableReason = "Operation cancelled by caller." + }; } catch (Exception e) { @@ -19,7 +33,11 @@ public sealed partial class RustService else Console.WriteLine($"Error while fetching Qdrant info from Rust service: '{e}'."); - return default; + return new QdrantInfo + { + Status = QdrantStatus.UNAVAILABLE, + UnavailableReason = e.Message + }; } } } \ No newline at end of file diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index b52be5a5..dd54e205 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -25,7 +25,7 @@ use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_serv use crate::environment::{is_prod, is_dev, CONFIG_DIRECTORY, DATA_DIRECTORY}; use crate::log::switch_to_file_logging; use crate::pdfium::PDFIUM_LIB_PATH; -use crate::qdrant::{cleanup_qdrant, start_qdrant_server, stop_qdrant_server}; +use crate::qdrant::{start_qdrant_server, stop_qdrant_server}; #[cfg(debug_assertions)] use crate::dotnet::create_startup_env_file; @@ -148,7 +148,6 @@ pub fn start_tauri() { start_dotnet_server(app.handle().clone()); } - cleanup_qdrant(); start_qdrant_server(app.handle().clone()); info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}"); diff --git a/runtime/src/qdrant.rs b/runtime/src/qdrant.rs index 2ec9d1e9..639dd7c7 100644 --- a/runtime/src/qdrant.rs +++ b/runtime/src/qdrant.rs @@ -5,6 +5,7 @@ use std::fs::File; use std::io::Write; use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; use log::{debug, error, info, warn}; use once_cell::sync::Lazy; use axum::Json; @@ -18,6 +19,7 @@ use tauri::path::BaseDirectory; use tempfile::{TempDir, Builder}; use crate::stale_process_cleanup::{kill_stale_process, log_potential_stale_process}; use crate::sidecar_types::SidecarType; +use tokio::time; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; @@ -40,14 +42,24 @@ static API_TOKEN: Lazy = Lazy::new(|| { }); static TMPDIR: Lazy>> = Lazy::new(|| Mutex::new(None)); -static QDRANT_STATUS: Lazy> = Lazy::new(|| Mutex::new(QdrantStatus::default())); +static QDRANT_STATUS: Lazy> = Lazy::new(|| Mutex::new(QdrantStatusInfo::default())); const PID_FILE_NAME: &str = "qdrant.pid"; const SIDECAR_TYPE:SidecarType = SidecarType::Qdrant; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(60); +const STARTUP_CHECK_INTERVAL: Duration = Duration::from_millis(250); + +#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)] +enum QdrantStatus { + #[default] + Starting, + Available, + Unavailable, +} #[derive(Default)] -struct QdrantStatus { - is_available: bool, +struct QdrantStatusInfo { + status: QdrantStatus, unavailable_reason: Option, } @@ -60,6 +72,7 @@ fn qdrant_base_path() -> PathBuf { #[derive(Serialize)] pub struct ProvideQdrantInfo { + status: QdrantStatus, path: String, port_http: u16, port_grpc: u16, @@ -71,10 +84,12 @@ pub struct ProvideQdrantInfo { pub async fn qdrant_port(_token: APIToken) -> Json { let status = QDRANT_STATUS.lock().unwrap(); - let is_available = status.is_available; + let current_status = status.status; + let is_available = current_status == QdrantStatus::Available; let unavailable_reason = status.unavailable_reason.clone(); Json(ProvideQdrantInfo { + status: current_status, path: if is_available { qdrant_base_path().to_string_lossy().to_string() } else { @@ -99,6 +114,14 @@ pub async fn qdrant_port(_token: APIToken) -> Json { /// Starts the Qdrant server in a separate process. pub fn start_qdrant_server(app_handle: tauri::AppHandle){ + set_qdrant_starting(); + tauri::async_runtime::spawn(async move { + cleanup_qdrant(); + start_qdrant_server_internal(app_handle); + }); +} + +fn start_qdrant_server_internal(app_handle: tauri::AppHandle){ let path = qdrant_base_path(); if !path.exists() && let Err(e) = fs::create_dir_all(&path){ error!(Source="Qdrant"; "The required directory to host the Qdrant database could not be created: {}", e); @@ -117,12 +140,13 @@ pub fn start_qdrant_server(app_handle: tauri::AppHandle){ let storage_path = path.join("storage").to_string_lossy().to_string(); let snapshot_path = path.join("snapshots").to_string_lossy().to_string(); - let init_path = path.join(".qdrant-initialized").to_string_lossy().to_string(); + let init_path = path.join(".qdrant-initialized"); + let init_path_environment = init_path.to_string_lossy().to_string(); let qdrant_server_environment: HashMap = HashMap::from_iter([ (String::from("QDRANT__SERVICE__HTTP_PORT"), QDRANT_SERVER_PORT_HTTP.to_string()), (String::from("QDRANT__SERVICE__GRPC_PORT"), QDRANT_SERVER_PORT_GRPC.to_string()), - (String::from("QDRANT_INIT_FILE_PATH"), init_path), + (String::from("QDRANT_INIT_FILE_PATH"), init_path_environment), (String::from("QDRANT__STORAGE__STORAGE_PATH"), storage_path), (String::from("QDRANT__STORAGE__SNAPSHOTS_PATH"), snapshot_path), (String::from("QDRANT__TLS__CERT"), cert_path.to_string_lossy().to_string()), @@ -172,13 +196,24 @@ pub fn start_qdrant_server(app_handle: tauri::AppHandle){ }; let server_pid = child.pid(); - set_qdrant_available(); info!(Source = "Bootloader Qdrant"; "Qdrant server process started with PID={server_pid}."); log_potential_stale_process(path.join(PID_FILE_NAME), server_pid, SIDECAR_TYPE); // Save the server process to stop it later: *server_spawn_clone.lock().unwrap() = Some(child); + let init_path_clone = init_path.clone(); + tauri::async_runtime::spawn(async move { + if wait_for_qdrant_startup(init_path_clone).await { + set_qdrant_available(); + info!(Source = "Qdrant"; "Qdrant is available."); + } else { + let reason = "Qdrant did not become available within the startup timeout.".to_string(); + error!(Source = "Qdrant"; "{reason}"); + set_qdrant_unavailable(reason); + } + }); + // Log the output of the Qdrant server: while let Some(event) = rx.recv().await { match event { @@ -200,10 +235,18 @@ pub fn start_qdrant_server(app_handle: tauri::AppHandle){ let line_utf8 = String::from_utf8_lossy(&line).to_string(); error!(Source = "Qdrant Server (stderr)"; "{line_utf8}"); }, - + _ => {} } } + + let is_available = QDRANT_STATUS.lock().unwrap().status == QdrantStatus::Available; + let unavailable_reason = if is_available { + "Qdrant server process stopped.".to_string() + } else { + "Qdrant server process stopped before it became available.".to_string() + }; + set_qdrant_unavailable(unavailable_reason); }); } @@ -226,6 +269,20 @@ pub fn stop_qdrant_server() { cleanup_qdrant(); } +async fn wait_for_qdrant_startup(init_path: PathBuf) -> bool { + let mut elapsed = Duration::ZERO; + while elapsed < STARTUP_TIMEOUT { + if init_path.exists() { + return true; + } + + time::sleep(STARTUP_CHECK_INTERVAL).await; + elapsed += STARTUP_CHECK_INTERVAL; + } + + false +} + /// Create a temporary directory with TLS relevant files pub fn create_temp_tls_files(path: &PathBuf) -> Result<(PathBuf, PathBuf), Box> { let cert = generate_certificate(); @@ -278,13 +335,19 @@ pub fn cleanup_qdrant() { fn set_qdrant_available() { let mut status = QDRANT_STATUS.lock().unwrap(); - status.is_available = true; + status.status = QdrantStatus::Available; + status.unavailable_reason = None; +} + +fn set_qdrant_starting() { + let mut status = QDRANT_STATUS.lock().unwrap(); + status.status = QdrantStatus::Starting; status.unavailable_reason = None; } fn set_qdrant_unavailable(reason: String) { let mut status = QDRANT_STATUS.lock().unwrap(); - status.is_available = false; + status.status = QdrantStatus::Unavailable; status.unavailable_reason = Some(reason); }