Skip to content
Merged
1 change: 0 additions & 1 deletion playground/TestShop/CatalogDb/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,3 @@
app.MapDefaultEndpoints();

await app.RunAsync();

12 changes: 7 additions & 5 deletions playground/TestShop/TestShop.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
});
#endif

var catalogDbApp = builder.AddProject<Projects.CatalogDb>("catalogdbapp")
.WithReference(catalogDb);

var catalogService = builder.AddProject<Projects.CatalogService>("catalogservice")
.WithReference(catalogDb)
.WithReplicas(2);
.WithReplicas(2)
.WaitForCompletion(catalogDbApp);

var messaging = builder.AddRabbitMQ("messaging")
.WithDataVolume()
Expand All @@ -34,15 +38,13 @@
.WithReference(catalogService);

builder.AddProject<Projects.OrderProcessor>("orderprocessor", launchProfileName: "OrderProcessor")
.WithReference(messaging);
.WithReference(messaging)
.WaitFor(messaging);

builder.AddProject<Projects.ApiGateway>("apigateway")
.WithReference(basketService)
.WithReference(catalogService);

builder.AddProject<Projects.CatalogDb>("catalogdbapp")
.WithReference(catalogDb);

#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
// of the dashboard. It is not required in end developer code. Comment out this code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
Class="severity-icon" />
}
}
else if (Resource.IsStartingOrBuilding())
else if (Resource.IsStartingOrBuildingOrWaiting())
{
<FluentIcon Icon="Icons.Regular.Size16.CircleHint"
Color="Color.Info"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Dashboard.Model;
Expand Down Expand Up @@ -27,9 +27,9 @@ public static bool IsStopped(this ResourceViewModel resource)
return resource.KnownState is KnownResourceState.Exited or KnownResourceState.Finished or KnownResourceState.FailedToStart;
}

public static bool IsStartingOrBuilding(this ResourceViewModel resource)
public static bool IsStartingOrBuildingOrWaiting(this ResourceViewModel resource)
{
return resource.KnownState is KnownResourceState.Starting or KnownResourceState.Building;
return resource.KnownState is KnownResourceState.Starting or KnownResourceState.Building or KnownResourceState.Waiting;
}

public static bool HasNoState(this ResourceViewModel resource) => string.IsNullOrEmpty(resource.State);
Expand Down
5 changes: 3 additions & 2 deletions src/Aspire.Dashboard/Model/KnownResourceState.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Dashboard.Model;
Expand All @@ -11,5 +11,6 @@ public enum KnownResourceState
Starting,
Running,
Building,
Hidden
Hidden,
Waiting
}
10 changes: 10 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,14 @@ public static class KnownResourceStates
/// The finished state. Useful for showing the resource has finished.
/// </summary>
public static readonly string Finished = nameof(Finished);

/// <summary>
/// The waiting state. Useful for showing the resource is waiting for a dependency.
/// </summary>
public static readonly string Waiting = nameof(Waiting);

/// <summary>
/// List of terminal states.
/// </summary>
public static readonly string[] TerminalStates = [Finished, FailedToStart, Exited];
}
32 changes: 32 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,38 @@ public async Task<string> WaitForResourceAsync(string resourceName, IEnumerable<
throw new OperationCanceledException($"The operation was cancelled before the resource reached one of the target states: [{string.Join(", ", targetStates)}]");
}

/// <summary>
/// Waits for a resource to reach one of the specified states. See <see cref="KnownResourceStates"/> for common states.
/// </summary>
/// <remarks>
/// This method returns a task that will complete when the resource reaches one of the specified target states. If the resource
/// is already in the target state, the method will return immediately.<br/>
/// If the resource doesn't reach one of the target states before <paramref name="cancellationToken"/> is signaled, this method
/// will throw <see cref="OperationCanceledException"/>.
/// </remarks>
/// <param name="resourceName">The name of the resource.</param>
/// <param name="predicate">A predicate which is evaluated for each <see cref="ResourceEvent"/> for the selected resource.</param>
/// <param name="cancellationToken">A cancellation token that cancels the wait operation when signaled.</param>
/// <returns>A <see cref="Task{ResourceEvent}"/> representing the wait operation and which of the target states the resource reached.</returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters",
Justification = "targetState(s) parameters are mutually exclusive.")]
public async Task<ResourceEvent> WaitForResourceAsync(string resourceName, Func<ResourceEvent, bool> predicate, CancellationToken cancellationToken = default)
{
using var watchCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationStopping, cancellationToken);
var watchToken = watchCts.Token;
await foreach (var resourceEvent in WatchAsync(watchToken).ConfigureAwait(false))
{
if (string.Equals(resourceName, resourceEvent.Resource.Name, StringComparisons.ResourceName)
&& resourceEvent.Snapshot.State?.Text is { Length: > 0 } statusText
&& predicate(resourceEvent))
{
return resourceEvent;
}
}

throw new OperationCanceledException($"The operation was cancelled before the resource met the predicate condition.");
}

/// <summary>
/// Watch for changes to the state for all resources.
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Hosting/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Aspire.Hosting.ApplicationModel.BeforeStartEvent.BeforeStartEvent(System.IServic
Aspire.Hosting.ApplicationModel.BeforeStartEvent.Model.get -> Aspire.Hosting.ApplicationModel.DistributedApplicationModel!
Aspire.Hosting.ApplicationModel.BeforeStartEvent.Services.get -> System.IServiceProvider!
Aspire.Hosting.ApplicationModel.ResourceNotificationService.ResourceNotificationService(Microsoft.Extensions.Logging.ILogger<Aspire.Hosting.ApplicationModel.ResourceNotificationService!>! logger, Microsoft.Extensions.Hosting.IHostApplicationLifetime! hostApplicationLifetime) -> void
Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync(string! resourceName, System.Func<Aspire.Hosting.ApplicationModel.ResourceEvent!, bool>! predicate, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Aspire.Hosting.ApplicationModel.ResourceEvent!>!
Aspire.Hosting.DistributedApplicationBuilder.Eventing.get -> Aspire.Hosting.Eventing.IDistributedApplicationEventing!
Aspire.Hosting.Eventing.DistributedApplicationEventing
Aspire.Hosting.Eventing.DistributedApplicationEventing.DistributedApplicationEventing() -> void
Expand Down Expand Up @@ -49,6 +50,8 @@ Aspire.Hosting.DistributedApplicationExecutionContextOptions.DistributedApplicat
Aspire.Hosting.DistributedApplicationExecutionContextOptions.Operation.get -> Aspire.Hosting.DistributedApplicationOperation
Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.get -> System.IServiceProvider?
Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.set -> void
static Aspire.Hosting.ResourceBuilderExtensions.WaitFor<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.IResource!>! dependency) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T>!
static Aspire.Hosting.ResourceBuilderExtensions.WaitForCompletion<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T>! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.IResource!>! dependency, int exitCode = 0) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T>!
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Exited -> string!
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.FailedToStart -> string!
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Finished -> string!
Expand All @@ -72,3 +75,5 @@ static Aspire.Hosting.ContainerResourceBuilderExtensions.WithBuildArg<T>(this As
static Aspire.Hosting.ContainerResourceBuilderExtensions.WithBuildSecret<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T!>! builder, string! name, Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.ParameterResource!>! value) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T!>!
static Aspire.Hosting.ProjectResourceBuilderExtensions.AddProject(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! projectPath, System.Action<Aspire.Hosting.ProjectResourceOptions!>! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.ProjectResource!>!
static Aspire.Hosting.ProjectResourceBuilderExtensions.AddProject<TProject>(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Action<Aspire.Hosting.ProjectResourceOptions!>! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.ProjectResource!>!
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.TerminalStates -> string![]!
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Waiting -> string!
111 changes: 111 additions & 0 deletions src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using System.Net.Sockets;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting;

Expand Down Expand Up @@ -549,4 +551,113 @@ public static IResourceBuilder<T> ExcludeFromManifest<T>(this IResourceBuilder<T
{
return builder.WithAnnotation(ManifestPublishingCallbackAnnotation.Ignore);
}

/// <summary>
/// Waits for the dependency resource to enter the Running state before starting the resource.
/// </summary>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <param name="builder">The resource builder for the resource that will be waiting.</param>
/// <param name="dependency">The resource builder for the dependency resource.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// <para>This method is useful when a resource should wait until another has started running. This can help
/// reduce errors in logs during local development where dependency resources.</para>
/// </remarks>
/// <example>
/// Start message queue before starting the worker service.
/// <code lang="C#">
/// var builder = DistributedApplication.CreateBuilder(args);
/// var messaging = builder.AddRabbitMQ("messaging");
/// builder.AddProject&lt;Projects.MyApp&gt;("myapp")
/// .WithReference(messaging)
/// .WaitFor(messaging);
/// </code>
/// </example>
public static IResourceBuilder<T> WaitFor<T>(this IResourceBuilder<T> builder, IResourceBuilder<IResource> dependency) where T : IResource
{
builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (e, ct) =>
Copy link
Member

Choose a reason for hiding this comment

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

Another downside to doing it this way instead of a single wait for callback with lots of data is that first one to throw will stop other errors from coming through since the wait is sequential.

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe for this event we should invoke them all in parallel and then await them all together.

{
var rls = e.Services.GetRequiredService<ResourceLoggerService>();
var resourceLogger = rls.GetLogger(builder.Resource);
resourceLogger.LogInformation("Waiting for resource '{Name}' to enter the '{State}' state.", dependency.Resource.Name, KnownResourceStates.Running);

var rns = e.Services.GetRequiredService<ResourceNotificationService>();
await rns.PublishUpdateAsync(builder.Resource, s => s with { State = KnownResourceStates.Waiting }).ConfigureAwait(false);
await rns.WaitForResourceAsync(dependency.Resource.Name, cancellationToken: ct).ConfigureAwait(false);
});

return builder;
}

/// <summary>
/// Waits for the dependency resource to enter the Exited or Finished state before starting the resource.
/// </summary>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <param name="builder">The resource builder for the resource that will be waiting.</param>
/// <param name="dependency">The resource builder for the dependency resource.</param>
/// <param name="exitCode">The exit code which is interpretted as successful.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// <para>This method is useful when a resource should wait until another has completed. A common usage pattern
/// would be to include a console application that initializes the database schema or performs other one off
/// initialization tasks.</para>
/// <para>Note that this method has no impact at deployment time and only works for local development.</para>
/// </remarks>
/// <example>
/// Wait for database initialization app to complete running.
/// <code lang="C#">
/// var builder = DistributedApplication.CreateBuilder(args);
/// var pgsql = builder.AddPostgres("postgres");
/// var dbprep = builder.AddProject&lt;Projects.DbPrepApp&gt;("dbprep")
/// .WithReference(pgsql);
/// builder.AddProject&lt;Projects.DatabasePrepTool&gt;("dbprep")
/// .WithReference(pgsql)
/// .WaitForCompletion(dbprep);
/// </code>
/// </example>
public static IResourceBuilder<T> WaitForCompletion<T>(this IResourceBuilder<T> builder, IResourceBuilder<IResource> dependency, int exitCode = 0) where T : IResource
{
builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (e, ct) =>
{
// TODO: Decide how we want to interpret inconsistent results from replicas of projects. For now
// if we detect that the project is configured for replicas we will throw an exception.
if (dependency.Resource.Annotations.Any(a => a is ReplicaAnnotation ra && ra.Replicas > 1))
{
throw new DistributedApplicationException("WaitForCompletion cannot be used with resources that have replicas.");
}

var rls = e.Services.GetRequiredService<ResourceLoggerService>();
var resourceLogger = rls.GetLogger(builder.Resource);
resourceLogger.LogInformation($"Waiting for resource '{dependency.Resource.Name}' to complete.");

var rns = e.Services.GetRequiredService<ResourceNotificationService>();
await rns.PublishUpdateAsync(builder.Resource, s => s with { State = KnownResourceStates.Waiting }).ConfigureAwait(false);
var resourceEvent = await rns.WaitForResourceAsync(dependency.Resource.Name, re => IsKnownTerminalState(re.Snapshot), cancellationToken: ct).ConfigureAwait(false);
var snapshot = resourceEvent.Snapshot;

if (snapshot.State == KnownResourceStates.FailedToStart)
{
throw new DistributedApplicationException("Dependency resource failed to start.");
}
else if ((snapshot.State!.Text == KnownResourceStates.Finished || snapshot.State!.Text == KnownResourceStates.Exited) && snapshot.ExitCode != exitCode)
{
resourceLogger.LogInformation(
"Resource '{ResourceName}' has entered the '{State}' state with exit code '{ExitCode}'",
dependency.Resource.Name,
snapshot.State.Text,
snapshot.ExitCode
);

throw new DistributedApplicationException(
$"Resource '{dependency.Resource.Name}' has entered the '{snapshot.State.Text}' state with exit code '{snapshot.ExitCode}'"
);
}
});

return builder;

static bool IsKnownTerminalState(CustomResourceSnapshot snapshot) =>
KnownResourceStates.TerminalStates.Contains(snapshot.State?.Text) &&
snapshot.ExitCode is not null;
}
}
Loading