Skip to content

Commit facc75f

Browse files
authored
Allow the ResourceReadyEvent to block waiters (#7163)
* Allow the ResourceReadyEvent to block waiters - This allows more easier coordination after a resource is healthy but before waiters are unblocked. This is useful for seeding etc.
1 parent 405a54b commit facc75f

File tree

6 files changed

+142
-12
lines changed

6 files changed

+142
-12
lines changed

playground/mongo/Mongo.AppHost/Program.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,40 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using MongoDB.Bson.Serialization.Attributes;
5+
using MongoDB.Bson;
6+
using MongoDB.Driver;
7+
48
var builder = DistributedApplication.CreateBuilder(args);
59

610
var db = builder.AddMongoDB("mongo")
711
.WithMongoExpress(c => c.WithHostPort(3022))
812
.AddDatabase("db");
913

14+
builder.Eventing.Subscribe<ResourceReadyEvent>(db.Resource, async (@event, ct) =>
15+
{
16+
// Artificial delay to demonstrate the waiting
17+
await Task.Delay(TimeSpan.FromSeconds(10), ct);
18+
19+
// Seed the database with some data
20+
var cs = await db.Resource.ConnectionStringExpression.GetValueAsync(ct);
21+
using var client = new MongoClient(cs);
22+
23+
const string collectionName = "entries";
24+
25+
var myDb = client.GetDatabase("db");
26+
await myDb.CreateCollectionAsync(collectionName, cancellationToken: ct);
27+
28+
for (int i = 0; i < 10; i++)
29+
{
30+
await myDb.GetCollection<Entry>(collectionName).InsertOneAsync(new Entry(), cancellationToken: ct);
31+
}
32+
});
33+
1034
builder.AddProject<Projects.Mongo_ApiService>("api")
1135
.WithExternalHttpEndpoints()
12-
.WithReference(db).WaitFor(db);
36+
.WithReference(db)
37+
.WaitFor(db);
1338

1439
#if !SKIP_DASHBOARD_REFERENCE
1540
// This project is only added in playground projects to support development/debugging
@@ -22,3 +47,10 @@
2247
#endif
2348

2449
builder.Build().Run();
50+
51+
public sealed class Entry
52+
{
53+
[BsonId]
54+
[BsonRepresentation(BsonType.ObjectId)]
55+
public string? Id { get; set; }
56+
}

src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ public ResourceStateSnapshot? State
6161
/// </summary>
6262
public int? ExitCode { get; init; }
6363

64+
/// <summary>
65+
/// A snapshot of the event that indicates the resource is ready.
66+
/// </summary>
67+
internal EventSnapshot? ResourceReadyEvent { get; init; }
68+
6469
/// <summary>
6570
/// Gets the health status of the resource.
6671
/// </summary>
@@ -130,6 +135,12 @@ internal init
130135
}
131136
}
132137

138+
/// <summary>
139+
/// A snapshot of an event.
140+
/// </summary>
141+
/// <param name="EventTask">The task the represents the result of executing the event.</param>
142+
internal record EventSnapshot(Task EventTask);
143+
133144
/// <summary>
134145
/// A snapshot of the resource state
135146
/// </summary>

src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ private async Task WaitUntilHealthyAsync(IResource resource, IResource dependenc
161161
await WaitForResourceHealthyAsync(dependency.Name, cancellationToken).ConfigureAwait(false);
162162
}
163163

164+
// Now wait for the resource ready event to be executed.
165+
resourceLogger.LogInformation("Waiting for resource ready to execute for '{Name}'.", dependency.Name);
166+
resourceEvent = await WaitForResourceAsync(dependency.Name, re => re.Snapshot.ResourceReadyEvent is not null, cancellationToken: cancellationToken).ConfigureAwait(false);
167+
168+
// Observe the result of the resource ready event task
169+
await resourceEvent.Snapshot.ResourceReadyEvent!.EventTask.WaitAsync(cancellationToken).ConfigureAwait(false);
170+
164171
resourceLogger.LogInformation("Finished waiting for resource '{Name}'.", dependency.Name);
165172

166173
static bool IsContinuableState(CustomResourceSnapshot snapshot) =>

src/Aspire.Hosting/Health/ResourceHealthCheckService.cs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,37 @@ private async Task MonitorResourceHealthAsync(ResourceEvent initialEvent, Cancel
4545
var resource = initialEvent.Resource;
4646
var resourceReadyEventFired = false;
4747

48+
void FireResourceReadyEvent()
49+
{
50+
// We don't want to block the monitoring loop while we fire the event.
51+
_ = Task.Run(async () =>
52+
{
53+
var resourceReadyEvent = new ResourceReadyEvent(resource, services);
54+
55+
// Execute the publish and store the task so that waiters can await it and observe the result.
56+
var task = eventing.PublishAsync(resourceReadyEvent, cancellationToken);
57+
58+
// Suppress exceptions, we just want to make sure that the event is completed.
59+
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
60+
61+
await resourceNotificationService.PublishUpdateAsync(resource, s => s with
62+
{
63+
ResourceReadyEvent = new(task)
64+
})
65+
.ConfigureAwait(false);
66+
},
67+
cancellationToken);
68+
}
69+
4870
if (!resource.TryGetAnnotationsIncludingAncestorsOfType<HealthCheckAnnotation>(out var annotations))
4971
{
5072
// NOTE: If there are no health check annotations then there
5173
// is currently nothing to monitor. At this point in time we don't
5274
// dynamically add health checks at runtime. If this changes then we
5375
// would need to revisit this and scan for transitive health checks
5476
// on a periodic basis (you wouldn't want to do it on every pass.
55-
var resourceReadyEvent = new ResourceReadyEvent(resource, services);
56-
await eventing.PublishAsync(
57-
resourceReadyEvent,
58-
EventDispatchBehavior.NonBlockingSequential,
59-
cancellationToken).ConfigureAwait(false);
77+
FireResourceReadyEvent();
78+
6079
return;
6180
}
6281

@@ -76,11 +95,8 @@ await eventing.PublishAsync(
7695
if (!resourceReadyEventFired && report.Status == HealthStatus.Healthy)
7796
{
7897
resourceReadyEventFired = true;
79-
var resourceReadyEvent = new ResourceReadyEvent(resource, services);
80-
await eventing.PublishAsync(
81-
resourceReadyEvent,
82-
EventDispatchBehavior.NonBlockingSequential,
83-
cancellationToken).ConfigureAwait(false);
98+
99+
FireResourceReadyEvent();
84100
}
85101

86102
var latestEvent = _latestEvents.GetValueOrDefault(resource.Name);

tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ void Configure(DistributedApplicationOptions applicationOptions, HostApplication
108108
public TestDistributedApplicationBuilder WithTestAndResourceLogging(ITestOutputHelper testOutputHelper)
109109
{
110110
Services.AddXunitLogging(testOutputHelper);
111-
Services.AddHostedService<ResourceLoggerForwarderService>();
111+
Services.Insert(0, ServiceDescriptor.Singleton<IHostedService, ResourceLoggerForwarderService>());
112112
Services.AddLogging(builder =>
113113
{
114114
builder.AddFilter("Aspire.Hosting", LogLevel.Trace);

tests/Aspire.Hosting.Tests/WaitForTests.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Aspire.Hosting.Utils;
66
using Microsoft.AspNetCore.InternalTesting;
77
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Logging;
89
using Xunit;
910
using Xunit.Abstractions;
1011

@@ -216,6 +217,69 @@ await rns.PublishUpdateAsync(dependency.Resource, s => s with
216217
await app.StopAsync();
217218
}
218219

220+
[Fact]
221+
[RequiresDocker]
222+
public async Task WaitForObservedResultOfResourceReadyEvent()
223+
{
224+
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper);
225+
226+
builder.Services.AddLogging(b =>
227+
{
228+
b.AddFakeLogging();
229+
});
230+
231+
var resourceReadyTcs = new TaskCompletionSource();
232+
var dependency = builder.AddResource(new CustomResource("test"));
233+
var nginx = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22")
234+
.WithReference(dependency)
235+
.WaitFor(dependency);
236+
237+
builder.Eventing.Subscribe<ResourceReadyEvent>(dependency.Resource, (e, ct) => resourceReadyTcs.Task);
238+
239+
using var app = builder.Build();
240+
241+
// StartAsync will currently block until the dependency resource moves
242+
// into a Finished state, so rather than awaiting it we'll hold onto the
243+
// task so we can inspect the state of the Nginx resource which should
244+
// be in a waiting state if everything is working correctly.
245+
var startupCts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration);
246+
var startTask = app.StartAsync(startupCts.Token);
247+
248+
// We don't want to wait forever for Nginx to move into a waiting state,
249+
// it should be super quick, but we'll allow 60 seconds just in case the
250+
// CI machine is chugging (also useful when collecting code coverage).
251+
var waitingStateCts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration);
252+
253+
var rns = app.Services.GetRequiredService<ResourceNotificationService>();
254+
await rns.WaitForResourceAsync(nginx.Resource.Name, "Waiting", waitingStateCts.Token);
255+
256+
// Now that we know we successfully entered the Waiting state, we can swap
257+
// the dependency into a running state which will unblock startup and
258+
// we can continue executing.
259+
await rns.PublishUpdateAsync(dependency.Resource, s => s with
260+
{
261+
State = KnownResourceStates.Running
262+
});
263+
264+
resourceReadyTcs.SetException(new InvalidOperationException("The resource ready event failed!"));
265+
266+
// This time we want to wait for Nginx to move into a Running state to verify that
267+
// it successfully started after we moved the dependency resource into the Finished, but
268+
// we need to give it more time since we have to download the image in CI.
269+
var runningStateCts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration);
270+
await rns.WaitForResourceAsync(nginx.Resource.Name, KnownResourceStates.FailedToStart, runningStateCts.Token);
271+
272+
await startTask;
273+
274+
var collector = app.Services.GetFakeLogCollector();
275+
var logs = collector.GetSnapshot();
276+
277+
// Just looking for a common message in Docker build output.
278+
Assert.Contains(logs, log => log.Message.Contains("The resource ready event failed!"));
279+
280+
await app.StopAsync();
281+
}
282+
219283
[Fact]
220284
[RequiresDocker]
221285
public async Task EnsureDependencyResourceThatReturnsNonMatchingExitCodeResultsInDependentResourceFailingToStart()

0 commit comments

Comments
 (0)