diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs index 977fc89e851..4dac66ee3fc 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -238,6 +238,9 @@ static bool IsContinuableState(WaitBehavior waitBehavior, CustomResourceSnapshot /// control this behavior use /// or configure the default behavior with . /// + /// + /// This method can be used independently of or together with the method. + /// /// public async Task WaitForResourceHealthyAsync(string resourceName, CancellationToken cancellationToken = default) { @@ -247,6 +250,92 @@ public async Task WaitForResourceHealthyAsync(string resourceName cancellationToken).ConfigureAwait(false); } + /// + /// Waits for a resource to be ready. + /// + /// The name of the resource. + /// The cancellation token. + /// A task. + /// + /// + /// This method returns a task that completes when all subscriptions to the ResourceReadyEvent + /// have completed (if any). If any throw an exception, this method will throw an exception. + /// If none are present this method will return immediately. + /// + /// + /// This method does not explicitly wait for the resource to be healthy and can be used + /// independently of or together with the method. + /// + /// + public async Task WaitForResourceReadyAsync(string resourceName, CancellationToken cancellationToken = default) + { + return await WaitForResourceReadyAsync( + resourceName, + DefaultWaitBehavior, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Waits for a resource to be ready. + /// + /// The name of the resource. + /// The wait behavior. + /// The cancellation token. + /// A task. + /// + /// + /// This method returns a task that completes when all subscriptions to the ResourceReadyEvent + /// have completed (if any). If any throw an exception, this method will throw an exception. + /// If none are present this method will return immediately. The + /// controls how the wait operation behaves when the resource + /// enters an unavailable state such as . + /// + /// + /// When is specified the wait operation + /// will continue to wait until the resource's ResourceReadyEvent is present. + /// + /// + /// When is specified the wait operation + /// will throw a if the resource enters an + /// unavailable state. + /// + /// + /// This method does not explicitly wait for the resource to be healthy and can be used + /// independently of or together with the method. + /// + /// + public async Task WaitForResourceReadyAsync(string resourceName, WaitBehavior waitBehavior, CancellationToken cancellationToken = default) + { + _logger.LogDebug("Waiting for resource '{Name}' to be ready.", resourceName); + var resourceEvent = await WaitForResourceCoreAsync(resourceName, re => ShouldYield(waitBehavior, re.Snapshot), cancellationToken: cancellationToken).ConfigureAwait(false); + + if (resourceEvent.Snapshot.ResourceReadyEvent is null) + { + _logger.LogError("Stopped waiting for resource '{ResourceName}' to be ready because it failed to start.", resourceName); + throw new DistributedApplicationException($"Stopped waiting for resource '{resourceName}' to be ready because it failed to start."); + } + + // Then await the EventTask to complete + await resourceEvent.Snapshot.ResourceReadyEvent.EventTask.WaitAsync(cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("Finished waiting for resource '{Name}' to be ready.", resourceName); + + return resourceEvent; + + // Determine if we should yield based on the wait behavior and the snapshot of the resource. + static bool ShouldYield(WaitBehavior waitBehavior, CustomResourceSnapshot snapshot) => + waitBehavior switch + { + WaitBehavior.WaitOnResourceUnavailable => snapshot.ResourceReadyEvent is not null, + WaitBehavior.StopOnResourceUnavailable => snapshot.ResourceReadyEvent is not null || + snapshot.State?.Text == KnownResourceStates.Finished || + snapshot.State?.Text == KnownResourceStates.Exited || + snapshot.State?.Text == KnownResourceStates.FailedToStart || + snapshot.State?.Text == KnownResourceStates.RuntimeUnhealthy, + _ => throw new DistributedApplicationException($"Unexpected wait behavior: {waitBehavior}") + }; + } + /// /// Waits for a resource to become healthy. /// @@ -271,6 +360,9 @@ public async Task WaitForResourceHealthyAsync(string resourceName /// will throw a if the resource enters an /// unavailable state. /// + /// + /// This method can be used independently of or together with the method. + /// /// public async Task WaitForResourceHealthyAsync(string resourceName, WaitBehavior waitBehavior, CancellationToken cancellationToken = default) { diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs index 7f4ba0774b9..aa1b7547237 100644 --- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -2,14 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; namespace Aspire.Hosting.Tests; -public class ResourceNotificationTests +public class ResourceNotificationTests(ITestOutputHelper outputHelper) { [Fact] public void InitialStateCanBeSpecified() @@ -526,6 +528,284 @@ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with Assert.Equal("Starting", value.Snapshot.State?.Text); } + [Fact] + public async Task WaitForResourceReadyAsync_ReturnsWhenResourceReadyEventIsPresent() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + // Create a completed task for the ResourceReadyEvent + var completedTask = Task.CompletedTask; + var resourceReadyEvent = new EventSnapshot(completedTask); + + var waitTask = notificationService.WaitForResourceReadyAsync("myResource"); + + // Publish an update with a ResourceReadyEvent + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + ResourceReadyEvent = resourceReadyEvent + }).DefaultTimeout(); + + var result = await waitTask.DefaultTimeout(); + + Assert.Equal(resource, result.Resource); + Assert.Equal("myResource", result.ResourceId); + Assert.NotNull(result.Snapshot.ResourceReadyEvent); + } + + [Fact] + public async Task WaitForResourceReadyAsync_WaitsForEventTaskToComplete() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + // Create a task completion source to control when the EventTask completes + var tcs = new TaskCompletionSource(); + var resourceReadyEvent = new EventSnapshot(tcs.Task); + + var waitTask = notificationService.WaitForResourceReadyAsync("myResource"); + + // Publish an update with a ResourceReadyEvent that hasn't completed yet + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + ResourceReadyEvent = resourceReadyEvent + }).DefaultTimeout(); + + // Wait should not complete yet since the EventTask hasn't completed + await Task.Delay(100); + Assert.False(waitTask.IsCompleted); + + // Complete the EventTask + tcs.SetResult(); + + // Now the wait should complete + var result = await waitTask.DefaultTimeout(); + + Assert.Equal(resource, result.Resource); + Assert.Equal("myResource", result.ResourceId); + Assert.NotNull(result.Snapshot.ResourceReadyEvent); + } + + [Fact] + public async Task WaitForResourceReadyAsync_ThrowsOperationCanceledExceptionWhenCanceled() + { + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + using var cts = new CancellationTokenSource(); + var waitTask = notificationService.WaitForResourceReadyAsync("myResource", cts.Token); + + cts.Cancel(); + + await Assert.ThrowsAsync(async () => + { + await waitTask; + }).DefaultTimeout(); + } + + [Fact] + public async Task WaitForResourceReadyAsync_ThrowsOperationCanceledExceptionWhenEventTaskIsCanceled() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + // Create a canceled task for the ResourceReadyEvent + using var eventCts = new CancellationTokenSource(); + eventCts.Cancel(); + var canceledTask = Task.FromCanceled(eventCts.Token); + var resourceReadyEvent = new EventSnapshot(canceledTask); + + var waitTask = notificationService.WaitForResourceReadyAsync("myResource"); + + // Publish an update with a ResourceReadyEvent that has a canceled task + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + ResourceReadyEvent = resourceReadyEvent + }).DefaultTimeout(); + + await Assert.ThrowsAsync(async () => + { + await waitTask; + }).DefaultTimeout(); + } + + [Fact] + public async Task WaitForResourceReadyAsync_ReturnsImmediatelyWhenResourceReadyEventAlreadyPresent() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + // Create a completed task for the ResourceReadyEvent + var completedTask = Task.CompletedTask; + var resourceReadyEvent = new EventSnapshot(completedTask); + + // Publish the ResourceReadyEvent first + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + ResourceReadyEvent = resourceReadyEvent + }).DefaultTimeout(); + + // Wait should complete immediately since the event is already present and completed + var waitTask = notificationService.WaitForResourceReadyAsync("myResource"); + var result = await waitTask.DefaultTimeout(); + + Assert.True(waitTask.IsCompletedSuccessfully); + Assert.Equal(resource, result.Resource); + Assert.Equal("myResource", result.ResourceId); + Assert.NotNull(result.Snapshot.ResourceReadyEvent); + } + + [Fact] + public async Task WaitForResourceReadyAsync_WithWaitBehaviorStopOnResourceUnavailable_ThrowsWhenResourceFailsToStart() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.StopOnResourceUnavailable); + + // Publish an update indicating the resource failed to start + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.FailedToStart + }).DefaultTimeout(); + + await Assert.ThrowsAsync(async () => + { + await waitTask; + }).DefaultTimeout(); + } + + [Fact] + public async Task WaitForResourceReadyAsync_WithWaitBehaviorStopOnResourceUnavailable_ThrowsWhenResourceEntersRuntimeUnhealthy() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.StopOnResourceUnavailable); + + // Publish an update indicating the resource is runtime unhealthy + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.RuntimeUnhealthy + }).DefaultTimeout(); + + await Assert.ThrowsAsync(async () => + { + await waitTask; + }).DefaultTimeout(); + } + + [Fact] + public async Task WaitForResourceReadyAsync_WithWaitBehaviorWaitOnResourceUnavailable_ContinuesWaitingWhenResourceFailsToStart() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.WaitOnResourceUnavailable); + + // Publish an update indicating the resource failed to start + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.FailedToStart + }).DefaultTimeout(); + + // Wait should not complete yet + await Task.Delay(100); + Assert.False(waitTask.IsCompleted); + + // Create a completed task for the ResourceReadyEvent + var completedTask = Task.CompletedTask; + var resourceReadyEvent = new EventSnapshot(completedTask); + + // Now publish a ResourceReadyEvent - this should complete the wait + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + ResourceReadyEvent = resourceReadyEvent + }).DefaultTimeout(); + + var result = await waitTask.DefaultTimeout(); + + Assert.Equal(resource, result.Resource); + Assert.Equal("myResource", result.ResourceId); + Assert.NotNull(result.Snapshot.ResourceReadyEvent); + } + + [Fact] + public async Task WaitForResourceReadyAsync_WithWaitBehaviorStopOnResourceUnavailable_ReturnsWhenResourceReadyEventIsPresent() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + // Create a completed task for the ResourceReadyEvent + var completedTask = Task.CompletedTask; + var resourceReadyEvent = new EventSnapshot(completedTask); + + var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.StopOnResourceUnavailable); + + // Publish an update with a ResourceReadyEvent + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + ResourceReadyEvent = resourceReadyEvent + }).DefaultTimeout(); + + var result = await waitTask.DefaultTimeout(); + + Assert.Equal(resource, result.Resource); + Assert.Equal("myResource", result.ResourceId); + Assert.NotNull(result.Snapshot.ResourceReadyEvent); + } + + [Fact] + public async Task WaitForResourceReadyAsync_WithWaitBehaviorWaitOnResourceUnavailable_ReturnsWhenResourceReadyEventIsPresent() + { + var resource = new CustomResource("myResource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + // Create a completed task for the ResourceReadyEvent + var completedTask = Task.CompletedTask; + var resourceReadyEvent = new EventSnapshot(completedTask); + + var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.WaitOnResourceUnavailable); + + // Publish an update with a ResourceReadyEvent + await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with + { + ResourceReadyEvent = resourceReadyEvent + }).DefaultTimeout(); + + var result = await waitTask.DefaultTimeout(); + + Assert.Equal(resource, result.Resource); + Assert.Equal("myResource", result.ResourceId); + Assert.NotNull(result.Snapshot.ResourceReadyEvent); + } + + [Fact] + public async Task WaitForResourceReadyAsync_EndToEnd() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); + + var ready = false; + + var cache = builder.AddRedis("cache") + .OnResourceReady((red, e, ct) => + { + ready = true; + return Task.CompletedTask; + }); + + var app = builder.Build(); + + await app.StartAsync(); + + var resourceNotification = app.Services.GetRequiredService(); + + // expected this to not complete until the OnResourceReady callback completes + await resourceNotification.WaitForResourceReadyAsync(cache.Resource.Name); + Assert.True(ready, "Resource should be ready after waiting."); + + await app.StopAsync(); + } + private sealed class CustomResource(string name) : Resource(name), IResourceWithEnvironment, IResourceWithConnectionString,