Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion playground/Qdrant/Qdrant.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

builder.AddProject<Projects.Qdrant_ApiService>("apiservice")
.WithExternalHttpEndpoints()
.WithReference(qdrant);
.WithReference(qdrant)
.WaitFor(qdrant);

builder.Build().Run();
1 change: 1 addition & 0 deletions src/Aspire.Hosting.Qdrant/Aspire.Hosting.Qdrant.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<ItemGroup>
<Compile Include="$(SharedDir)StringComparers.cs" Link="Utils\StringComparers.cs" />
<Compile Include="$(SharedDir)VolumeNameGenerator.cs" Link="Utils\VolumeNameGenerator.cs" />
<Compile Include="$(ComponentsDir)Aspire.Qdrant.Client\QdrantHealthCheck.cs" Link="QdrantHealthCheck.cs"></Compile>
</ItemGroup>

<ItemGroup>
Expand Down
70 changes: 69 additions & 1 deletion src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -42,6 +46,27 @@ public static IResourceBuilder<QdrantServerResource> 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<ConnectionStringAvailableEvent>(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)
Expand All @@ -61,7 +86,8 @@ public static IResourceBuilder<QdrantServerResource> AddQdrant(this IDistributed
{
context.EnvironmentVariables[EnableStaticContentEnvVarName] = "0";
}
});
})
.WithHealthCheck(healthCheckKey);
}

/// <summary>
Expand Down Expand Up @@ -117,4 +143,46 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(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.TryGetValue("Endpoint", out var endpointValue) && Uri.TryCreate(endpointValue.ToString(), UriKind.Absolute, out var serviceUri))
{
endpoint = serviceUri;
}

if (connectionBuilder.TryGetValue("Key", out var keyValue))
{
key = keyValue.ToString();
}
}

var factory = sp.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("qdrant-healthchecks");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make sure that this works with multiple Qdrant instances.

client.BaseAddress = endpoint;
if (key is not null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it technically possible that this client has any default headers coming from other configuration?

Suggested change
if (key is not null)
if (key is not null && !client.DefaultRequestHeaders.Contains("Api-Key"))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used named clients, so we don't need this.

{
client.DefaultRequestHeaders.Add("Api-Key", key);
}
return client;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<PackageReference Include="Qdrant.Client" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
</ItemGroup>

</Project>
32 changes: 32 additions & 0 deletions src/Components/Aspire.Qdrant.Client/QdrantHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -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<HealthCheckResult> 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);
}
}
}
42 changes: 42 additions & 0 deletions tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HealthCheckResult>();
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<ResourceNotificationService>();

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();
}
}
Loading