From e4a25c25ab308f3015cb00ef0a4db239428a423c Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Wed, 18 Sep 2024 19:34:31 +0330 Subject: [PATCH 1/3] WaitFor for Qdrant --- playground/Qdrant/Qdrant.AppHost/Program.cs | 3 +- .../Aspire.Hosting.Qdrant.csproj | 1 + .../QdrantBuilderExtensions.cs | 70 ++++++++++++++++++- .../Aspire.Qdrant.Client.csproj | 1 + .../Aspire.Qdrant.Client/QdrantHealthCheck.cs | 32 +++++++++ .../QdrantFunctionalTests.cs | 42 +++++++++++ 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 src/Components/Aspire.Qdrant.Client/QdrantHealthCheck.cs diff --git a/playground/Qdrant/Qdrant.AppHost/Program.cs b/playground/Qdrant/Qdrant.AppHost/Program.cs index f3df1ec05af..7577c79d38c 100644 --- a/playground/Qdrant/Qdrant.AppHost/Program.cs +++ b/playground/Qdrant/Qdrant.AppHost/Program.cs @@ -8,6 +8,7 @@ builder.AddProject("apiservice") .WithExternalHttpEndpoints() - .WithReference(qdrant); + .WithReference(qdrant) + .WaitFor(qdrant); builder.Build().Run(); diff --git a/src/Aspire.Hosting.Qdrant/Aspire.Hosting.Qdrant.csproj b/src/Aspire.Hosting.Qdrant/Aspire.Hosting.Qdrant.csproj index 4d9449a6847..ce2929322ea 100644 --- a/src/Aspire.Hosting.Qdrant/Aspire.Hosting.Qdrant.csproj +++ b/src/Aspire.Hosting.Qdrant/Aspire.Hosting.Qdrant.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs b/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs index 74174ca5881..10237d37c91 100644 --- a/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs +++ b/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs @@ -1,9 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Data.Common; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Qdrant; using Aspire.Hosting.Utils; +using Aspire.Qdrant.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Hosting; @@ -42,6 +46,27 @@ public static IResourceBuilder AddQdrant(this IDistributed var apiKeyParameter = apiKey?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-Key", special: false); var qdrant = new QdrantServerResource(name, apiKeyParameter); + + builder.Services.AddHttpClient(); + + HttpClient? httpClient = null; + + builder.Eventing.Subscribe(qdrant, async (@event, ct) => + { + var connectionString = await qdrant.HttpConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false) + ?? throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{qdrant.Name}' resource but the connection string was null."); + httpClient = CreateQdrantHttpClient(@event.Services, connectionString); + }); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks() + .Add(new HealthCheckRegistration( + healthCheckKey, + sp => new QdrantHealthCheck(httpClient!), + failureStatus: default, + tags: default, + timeout: default)); + return builder.AddResource(qdrant) .WithImage(QdrantContainerImageTags.Image, QdrantContainerImageTags.Tag) .WithImageRegistry(QdrantContainerImageTags.Registry) @@ -61,7 +86,8 @@ public static IResourceBuilder AddQdrant(this IDistributed { context.EnvironmentVariables[EnableStaticContentEnvVarName] = "0"; } - }); + }) + .WithHealthCheck(healthCheckKey); } /// @@ -117,4 +143,46 @@ public static IResourceBuilder WithReference(this IR return builder; } + + private static HttpClient CreateQdrantHttpClient(IServiceProvider sp, string? connectionString) + { + if (connectionString is null) + { + throw new InvalidOperationException("Connection string is unavailable"); + } + + Uri? endpoint = null; + string? key = null; + + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + endpoint = uri; + } + else + { + var connectionBuilder = new DbConnectionStringBuilder + { + ConnectionString = connectionString + }; + + if (connectionBuilder.ContainsKey("Endpoint") && Uri.TryCreate(connectionBuilder["Endpoint"].ToString(), UriKind.Absolute, out var serviceUri)) + { + endpoint = serviceUri; + } + + if (connectionBuilder.ContainsKey("Key")) + { + key = connectionBuilder["Key"].ToString(); + } + } + + var factory = sp.GetRequiredService(); + var client = factory.CreateClient(); + client.BaseAddress = endpoint; + if (key is not null) + { + client.DefaultRequestHeaders.Add("Api-Key", key); + } + return client; + } } diff --git a/src/Components/Aspire.Qdrant.Client/Aspire.Qdrant.Client.csproj b/src/Components/Aspire.Qdrant.Client/Aspire.Qdrant.Client.csproj index d92e5440de7..8a100936308 100644 --- a/src/Components/Aspire.Qdrant.Client/Aspire.Qdrant.Client.csproj +++ b/src/Components/Aspire.Qdrant.Client/Aspire.Qdrant.Client.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Components/Aspire.Qdrant.Client/QdrantHealthCheck.cs b/src/Components/Aspire.Qdrant.Client/QdrantHealthCheck.cs new file mode 100644 index 00000000000..e9593b9b8ca --- /dev/null +++ b/src/Components/Aspire.Qdrant.Client/QdrantHealthCheck.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Qdrant.Client; +internal sealed class QdrantHealthCheck : IHealthCheck +{ + private readonly HttpClient _client; + + public QdrantHealthCheck(HttpClient client) + { + ArgumentNullException.ThrowIfNull(client, nameof(client)); + _client = client; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + var response = await _client.GetAsync("/readyz", cancellationToken).ConfigureAwait(false); + + return response.IsSuccessStatusCode + ? HealthCheckResult.Healthy() + : new HealthCheckResult(HealthStatus.Unhealthy); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } +} diff --git a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs index 661d090148d..4d2f3df8231 100644 --- a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs +++ b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs @@ -2,10 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Components.Common.Tests; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; using Grpc.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Polly; using Qdrant.Client; @@ -216,4 +218,44 @@ await pipeline.ExecuteAsync(async token => } } } + + [Fact] + [RequiresDocker] + public async Task VerifyWaitForOnQdrantBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var resource = builder.AddQdrant("resource") + .WithHealthCheck("blocking_check"); + + var dependentResource = builder.AddQdrant("dependentresource") + .WaitFor(resource); + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + await rns.WaitForResourceAsync(resource.Resource.Name, (re => re.Snapshot.HealthStatus == HealthStatus.Healthy), cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; + + await app.StopAsync(); + } } From 92a0ab8547dca5ccc416d2e75036717bffedc253 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 19 Sep 2024 13:21:59 +0330 Subject: [PATCH 2/3] Address PR feedback --- src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs b/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs index 10237d37c91..1847e0946cb 100644 --- a/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs +++ b/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs @@ -165,19 +165,19 @@ private static HttpClient CreateQdrantHttpClient(IServiceProvider sp, string? co ConnectionString = connectionString }; - if (connectionBuilder.ContainsKey("Endpoint") && Uri.TryCreate(connectionBuilder["Endpoint"].ToString(), UriKind.Absolute, out var serviceUri)) + if (connectionBuilder.TryGetValue("Endpoint", out var endpointValue) && Uri.TryCreate(endpointValue.ToString(), UriKind.Absolute, out var serviceUri)) { endpoint = serviceUri; } - if (connectionBuilder.ContainsKey("Key")) + if (connectionBuilder.TryGetValue("Key", out var keyValue)) { - key = connectionBuilder["Key"].ToString(); + key = keyValue.ToString(); } } var factory = sp.GetRequiredService(); - var client = factory.CreateClient(); + var client = factory.CreateClient("qdrant-healthchecks"); client.BaseAddress = endpoint; if (key is not null) { From 2a59a7f4a166fb2d421e093e118d7cbfe6f08649 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Wed, 25 Sep 2024 22:21:59 +0330 Subject: [PATCH 3/3] Address PR feedback --- src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs b/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs index 1847e0946cb..0f805b10b98 100644 --- a/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs +++ b/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs @@ -47,15 +47,13 @@ public static IResourceBuilder AddQdrant(this IDistributed ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-Key", special: false); var qdrant = new QdrantServerResource(name, apiKeyParameter); - builder.Services.AddHttpClient(); - HttpClient? httpClient = null; builder.Eventing.Subscribe(qdrant, async (@event, ct) => { var connectionString = await qdrant.HttpConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{qdrant.Name}' resource but the connection string was null."); - httpClient = CreateQdrantHttpClient(@event.Services, connectionString); + httpClient = CreateQdrantHttpClient(@event.Services, connectionString, qdrant.Name); }); var healthCheckKey = $"{name}_check"; @@ -144,7 +142,7 @@ public static IResourceBuilder WithReference(this IR return builder; } - private static HttpClient CreateQdrantHttpClient(IServiceProvider sp, string? connectionString) + private static HttpClient CreateQdrantHttpClient(IServiceProvider sp, string? connectionString, string resourceName) { if (connectionString is null) { @@ -177,7 +175,7 @@ private static HttpClient CreateQdrantHttpClient(IServiceProvider sp, string? co } var factory = sp.GetRequiredService(); - var client = factory.CreateClient("qdrant-healthchecks"); + var client = factory.CreateClient($"{resourceName}-healthchecks"); client.BaseAddress = endpoint; if (key is not null) {