Skip to content

Commit

Permalink
Alirexaa/qdrant waitfor (#6044)
Browse files Browse the repository at this point in the history
* WaitFor for Qdrant

* Address PR feedback

* Address PR feedback

* Don't use logger factory.

---------

Co-authored-by: Alireza Baloochi <[email protected]>
  • Loading branch information
mitchdenny and Alirexaa authored Oct 1, 2024
1 parent 865775b commit f4e3bc3
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 2 deletions.
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
67 changes: 66 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,25 @@ public static IResourceBuilder<QdrantServerResource> AddQdrant(this IDistributed
var apiKeyParameter = apiKey?.Resource ??
ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-Key", special: false);
var qdrant = new QdrantServerResource(name, apiKeyParameter);

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(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 +84,8 @@ public static IResourceBuilder<QdrantServerResource> AddQdrant(this IDistributed
{
context.EnvironmentVariables[EnableStaticContentEnvVarName] = "0";
}
});
})
.WithHealthCheck(healthCheckKey);
}

/// <summary>
Expand Down Expand Up @@ -117,4 +141,45 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR

return builder;
}

private static HttpClient CreateQdrantHttpClient(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 client = new HttpClient();
client.BaseAddress = endpoint;
if (key is not null)
{
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();
}
}

0 comments on commit f4e3bc3

Please sign in to comment.