Improve Qdrant server startup & client initialization

This commit is contained in:
Thorsten Sommer 2026-05-18 20:36:38 +02:00
parent 97e6003686
commit 41b184318e
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
14 changed files with 425 additions and 79 deletions

View File

@ -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"

View File

@ -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<MetaDataAttribute>()!;
@ -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> 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);

View File

@ -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<UpdateService>();
builder.Services.AddHostedService<TemporaryChatService>();
builder.Services.AddHostedService<EnterpriseEnvironmentService>();
builder.Services.AddSingleton(databaseClient);
builder.Services.AddSingleton<DatabaseClientProvider>();
builder.Services.AddHostedService<GlobalShortcutService>();
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
@ -242,10 +199,7 @@ internal sealed class Program
RUST_SERVICE = rust;
ENCRYPTION = encryption;
var databaseLogger = app.Services.GetRequiredService<ILogger<DatabaseClient>>();
databaseClient.SetLogger(databaseLogger);
DATABASE_CLIENT = databaseClient;
DATABASE_CLIENT_PROVIDER = app.Services.GetRequiredService<DatabaseClientProvider>();
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.");
}

View File

@ -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;

View File

@ -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<DatabaseRole, DatabaseClient> clients = new();
private readonly Dictionary<DatabaseRole, SemaphoreSlim> locks = new();
private readonly Lock locksLock = new();
private readonly ILogger<DatabaseClientProvider> logger = loggerFactory.CreateLogger<DatabaseClientProvider>();
private readonly ILogger<DatabaseClient> databaseClientLogger = loggerFactory.CreateLogger<DatabaseClient>();
public async Task<DatabaseClient> 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<DatabaseClient> 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<DatabaseClient> 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<DatabaseClient> 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();
}
}

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.Databases;
public enum DatabaseClientStatus
{
STARTING,
AVAILABLE,
UNAVAILABLE,
}

View File

@ -0,0 +1,6 @@
namespace AIStudio.Tools.Databases;
public enum DatabaseRole
{
VECTOR_STORE,
}

View File

@ -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);

View File

@ -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<string> GetCollectionsAmount()
{
var operation = await this.GrpcClient.ListCollectionsAsync();

View File

@ -5,6 +5,8 @@
/// </summary>
public readonly record struct QdrantInfo
{
public QdrantStatus Status { get; init; }
public bool IsAvailable { get; init; }
public string? UnavailableReason { get; init; }

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.Rust;
public enum QdrantStatus
{
STARTING,
AVAILABLE,
UNAVAILABLE,
}

View File

@ -4,13 +4,27 @@ namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
public async Task<QdrantInfo> GetQdrantInfo()
public async Task<QdrantInfo> GetQdrantInfo(CancellationToken cancellationToken = default)
{
try
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var response = await this.http.GetFromJsonAsync<QdrantInfo>("/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<QdrantInfo>("/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
};
}
}
}

View File

@ -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:?}");

View File

@ -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<APIToken> = Lazy::new(|| {
});
static TMPDIR: Lazy<Mutex<Option<TempDir>>> = Lazy::new(|| Mutex::new(None));
static QDRANT_STATUS: Lazy<Mutex<QdrantStatus>> = Lazy::new(|| Mutex::new(QdrantStatus::default()));
static QDRANT_STATUS: Lazy<Mutex<QdrantStatusInfo>> = 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<String>,
}
@ -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<ProvideQdrantInfo> {
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<ProvideQdrantInfo> {
/// Starts the Qdrant server in a separate process.
pub fn start_qdrant_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
set_qdrant_starting();
tauri::async_runtime::spawn(async move {
cleanup_qdrant();
start_qdrant_server_internal(app_handle);
});
}
fn start_qdrant_server_internal<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
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<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
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<String, String> = 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<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
};
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<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
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<dyn Error>> {
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);
}