Skip to content

Commit 52bc399

Browse files
authored
WaitFor/WaitForCompletion implementation. (#5394)
* WaitFor/WaitForCompletion implementation.
1 parent ccefa98 commit 52bc399

File tree

11 files changed

+436
-11
lines changed

11 files changed

+436
-11
lines changed

playground/TestShop/CatalogDb/Program.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,3 @@
1818
app.MapDefaultEndpoints();
1919

2020
await app.RunAsync();
21-

playground/TestShop/TestShop.AppHost/Program.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
});
1616
#endif
1717

18+
var catalogDbApp = builder.AddProject<Projects.CatalogDb>("catalogdbapp")
19+
.WithReference(catalogDb);
20+
1821
var catalogService = builder.AddProject<Projects.CatalogService>("catalogservice")
1922
.WithReference(catalogDb)
2023
.WithReplicas(2);
@@ -40,9 +43,6 @@
4043
.WithReference(basketService)
4144
.WithReference(catalogService);
4245

43-
builder.AddProject<Projects.CatalogDb>("catalogdbapp")
44-
.WithReference(catalogDb);
45-
4646
#if !SKIP_DASHBOARD_REFERENCE
4747
// This project is only added in playground projects to support development/debugging
4848
// of the dashboard. It is not required in end developer code. Comment out this code

src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
Class="severity-icon" />
3333
}
3434
}
35-
else if (Resource.IsStartingOrBuilding())
35+
else if (Resource.IsStartingOrBuildingOrWaiting())
3636
{
3737
<FluentIcon Icon="Icons.Regular.Size16.CircleHint"
3838
Color="Color.Info"

src/Aspire.Dashboard/Extensions/ResourceViewModelExtensions.cs

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

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

30-
public static bool IsStartingOrBuilding(this ResourceViewModel resource)
30+
public static bool IsStartingOrBuildingOrWaiting(this ResourceViewModel resource)
3131
{
32-
return resource.KnownState is KnownResourceState.Starting or KnownResourceState.Building;
32+
return resource.KnownState is KnownResourceState.Starting or KnownResourceState.Building or KnownResourceState.Waiting;
3333
}
3434

3535
public static bool HasNoState(this ResourceViewModel resource) => string.IsNullOrEmpty(resource.State);

src/Aspire.Dashboard/Model/KnownResourceState.cs

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

44
namespace Aspire.Dashboard.Model;
@@ -11,5 +11,6 @@ public enum KnownResourceState
1111
Starting,
1212
Running,
1313
Building,
14-
Hidden
14+
Hidden,
15+
Waiting
1516
}

src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,14 @@ public static class KnownResourceStates
149149
/// The finished state. Useful for showing the resource has finished.
150150
/// </summary>
151151
public static readonly string Finished = nameof(Finished);
152+
153+
/// <summary>
154+
/// The waiting state. Useful for showing the resource is waiting for a dependency.
155+
/// </summary>
156+
public static readonly string Waiting = nameof(Waiting);
157+
158+
/// <summary>
159+
/// List of terminal states.
160+
/// </summary>
161+
public static readonly string[] TerminalStates = [Finished, FailedToStart, Exited];
152162
}

src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,38 @@ public async Task<string> WaitForResourceAsync(string resourceName, IEnumerable<
103103
throw new OperationCanceledException($"The operation was cancelled before the resource reached one of the target states: [{string.Join(", ", targetStates)}]");
104104
}
105105

106+
/// <summary>
107+
/// Waits for a resource to reach one of the specified states. See <see cref="KnownResourceStates"/> for common states.
108+
/// </summary>
109+
/// <remarks>
110+
/// This method returns a task that will complete when the resource reaches one of the specified target states. If the resource
111+
/// is already in the target state, the method will return immediately.<br/>
112+
/// If the resource doesn't reach one of the target states before <paramref name="cancellationToken"/> is signaled, this method
113+
/// will throw <see cref="OperationCanceledException"/>.
114+
/// </remarks>
115+
/// <param name="resourceName">The name of the resource.</param>
116+
/// <param name="predicate">A predicate which is evaluated for each <see cref="ResourceEvent"/> for the selected resource.</param>
117+
/// <param name="cancellationToken">A cancellation token that cancels the wait operation when signaled.</param>
118+
/// <returns>A <see cref="Task{ResourceEvent}"/> representing the wait operation and which of the target states the resource reached.</returns>
119+
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters",
120+
Justification = "targetState(s) parameters are mutually exclusive.")]
121+
public async Task<ResourceEvent> WaitForResourceAsync(string resourceName, Func<ResourceEvent, bool> predicate, CancellationToken cancellationToken = default)
122+
{
123+
using var watchCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationStopping, cancellationToken);
124+
var watchToken = watchCts.Token;
125+
await foreach (var resourceEvent in WatchAsync(watchToken).ConfigureAwait(false))
126+
{
127+
if (string.Equals(resourceName, resourceEvent.Resource.Name, StringComparisons.ResourceName)
128+
&& resourceEvent.Snapshot.State?.Text is { Length: > 0 } statusText
129+
&& predicate(resourceEvent))
130+
{
131+
return resourceEvent;
132+
}
133+
}
134+
135+
throw new OperationCanceledException($"The operation was cancelled before the resource met the predicate condition.");
136+
}
137+
106138
/// <summary>
107139
/// Watch for changes to the state for all resources.
108140
/// </summary>

src/Aspire.Hosting/PublicAPI.Unshipped.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Aspire.Hosting.ApplicationModel.BeforeStartEvent.BeforeStartEvent(System.IServic
1616
Aspire.Hosting.ApplicationModel.BeforeStartEvent.Model.get -> Aspire.Hosting.ApplicationModel.DistributedApplicationModel!
1717
Aspire.Hosting.ApplicationModel.BeforeStartEvent.Services.get -> System.IServiceProvider!
1818
Aspire.Hosting.ApplicationModel.ResourceNotificationService.ResourceNotificationService(Microsoft.Extensions.Logging.ILogger<Aspire.Hosting.ApplicationModel.ResourceNotificationService!>! logger, Microsoft.Extensions.Hosting.IHostApplicationLifetime! hostApplicationLifetime) -> void
19+
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!>!
1920
Aspire.Hosting.DistributedApplicationBuilder.Eventing.get -> Aspire.Hosting.Eventing.IDistributedApplicationEventing!
2021
Aspire.Hosting.Eventing.DistributedApplicationEventing
2122
Aspire.Hosting.Eventing.DistributedApplicationEventing.DistributedApplicationEventing() -> void
@@ -49,6 +50,8 @@ Aspire.Hosting.DistributedApplicationExecutionContextOptions.DistributedApplicat
4950
Aspire.Hosting.DistributedApplicationExecutionContextOptions.Operation.get -> Aspire.Hosting.DistributedApplicationOperation
5051
Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.get -> System.IServiceProvider?
5152
Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.set -> void
53+
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>!
54+
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>!
5255
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Exited -> string!
5356
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.FailedToStart -> string!
5457
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Finished -> string!
@@ -72,3 +75,5 @@ static Aspire.Hosting.ContainerResourceBuilderExtensions.WithBuildArg<T>(this As
7275
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!>!
7376
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!>!
7477
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!>!
78+
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.TerminalStates -> string![]!
79+
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Waiting -> string!

src/Aspire.Hosting/ResourceBuilderExtensions.cs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using System.Net.Sockets;
55
using Aspire.Hosting.ApplicationModel;
66
using Aspire.Hosting.Publishing;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Logging;
79

810
namespace Aspire.Hosting;
911

@@ -549,4 +551,145 @@ public static IResourceBuilder<T> ExcludeFromManifest<T>(this IResourceBuilder<T
549551
{
550552
return builder.WithAnnotation(ManifestPublishingCallbackAnnotation.Ignore);
551553
}
554+
555+
/// <summary>
556+
/// Waits for the dependency resource to enter the Running state before starting the resource.
557+
/// </summary>
558+
/// <typeparam name="T">The type of the resource.</typeparam>
559+
/// <param name="builder">The resource builder for the resource that will be waiting.</param>
560+
/// <param name="dependency">The resource builder for the dependency resource.</param>
561+
/// <returns>The resource builder.</returns>
562+
/// <remarks>
563+
/// <para>This method is useful when a resource should wait until another has started running. This can help
564+
/// reduce errors in logs during local development where dependency resources.</para>
565+
/// </remarks>
566+
/// <example>
567+
/// Start message queue before starting the worker service.
568+
/// <code lang="C#">
569+
/// var builder = DistributedApplication.CreateBuilder(args);
570+
/// var messaging = builder.AddRabbitMQ("messaging");
571+
/// builder.AddProject&lt;Projects.MyApp&gt;("myapp")
572+
/// .WithReference(messaging)
573+
/// .WaitFor(messaging);
574+
/// </code>
575+
/// </example>
576+
public static IResourceBuilder<T> WaitFor<T>(this IResourceBuilder<T> builder, IResourceBuilder<IResource> dependency) where T : IResource
577+
{
578+
builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (e, ct) =>
579+
{
580+
var rls = e.Services.GetRequiredService<ResourceLoggerService>();
581+
var resourceLogger = rls.GetLogger(builder.Resource);
582+
resourceLogger.LogInformation("Waiting for resource '{Name}' to enter the '{State}' state.", dependency.Resource.Name, KnownResourceStates.Running);
583+
584+
var rns = e.Services.GetRequiredService<ResourceNotificationService>();
585+
await rns.PublishUpdateAsync(builder.Resource, s => s with { State = KnownResourceStates.Waiting }).ConfigureAwait(false);
586+
var resourceEvent = await rns.WaitForResourceAsync(dependency.Resource.Name, re => IsContinuableState(re.Snapshot), cancellationToken: ct).ConfigureAwait(false);
587+
var snapshot = resourceEvent.Snapshot;
588+
589+
if (snapshot.State == KnownResourceStates.FailedToStart)
590+
{
591+
resourceLogger.LogError(
592+
"Dependency resource '{ResourceName}' failed to start.",
593+
dependency.Resource.Name
594+
);
595+
596+
throw new DistributedApplicationException($"Dependency resource '{dependency.Resource.Name}' failed to start.");
597+
}
598+
else if (snapshot.State!.Text == KnownResourceStates.Finished || snapshot.State!.Text == KnownResourceStates.Exited)
599+
{
600+
resourceLogger.LogError(
601+
"Resource '{ResourceName}' has entered the '{State}' state prematurely.",
602+
dependency.Resource.Name,
603+
snapshot.State.Text
604+
);
605+
606+
throw new DistributedApplicationException(
607+
$"Resource '{dependency.Resource.Name}' has entered the '{snapshot.State.Text}' state prematurely."
608+
);
609+
}
610+
});
611+
612+
return builder;
613+
614+
static bool IsContinuableState(CustomResourceSnapshot snapshot) =>
615+
snapshot.State?.Text == KnownResourceStates.Running ||
616+
snapshot.State?.Text == KnownResourceStates.Finished ||
617+
snapshot.State?.Text == KnownResourceStates.Exited ||
618+
snapshot.State?.Text == KnownResourceStates.FailedToStart;
619+
}
620+
621+
/// <summary>
622+
/// Waits for the dependency resource to enter the Exited or Finished state before starting the resource.
623+
/// </summary>
624+
/// <typeparam name="T">The type of the resource.</typeparam>
625+
/// <param name="builder">The resource builder for the resource that will be waiting.</param>
626+
/// <param name="dependency">The resource builder for the dependency resource.</param>
627+
/// <param name="exitCode">The exit code which is interpretted as successful.</param>
628+
/// <returns>The resource builder.</returns>
629+
/// <remarks>
630+
/// <para>This method is useful when a resource should wait until another has completed. A common usage pattern
631+
/// would be to include a console application that initializes the database schema or performs other one off
632+
/// initialization tasks.</para>
633+
/// <para>Note that this method has no impact at deployment time and only works for local development.</para>
634+
/// </remarks>
635+
/// <example>
636+
/// Wait for database initialization app to complete running.
637+
/// <code lang="C#">
638+
/// var builder = DistributedApplication.CreateBuilder(args);
639+
/// var pgsql = builder.AddPostgres("postgres");
640+
/// var dbprep = builder.AddProject&lt;Projects.DbPrepApp&gt;("dbprep")
641+
/// .WithReference(pgsql);
642+
/// builder.AddProject&lt;Projects.DatabasePrepTool&gt;("dbprep")
643+
/// .WithReference(pgsql)
644+
/// .WaitForCompletion(dbprep);
645+
/// </code>
646+
/// </example>
647+
public static IResourceBuilder<T> WaitForCompletion<T>(this IResourceBuilder<T> builder, IResourceBuilder<IResource> dependency, int exitCode = 0) where T : IResource
648+
{
649+
builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (e, ct) =>
650+
{
651+
if (dependency.Resource.TryGetLastAnnotation<ReplicaAnnotation>(out var replicaAnnotation) && replicaAnnotation.Replicas > 1)
652+
{
653+
throw new DistributedApplicationException("WaitForCompletion cannot be used with resources that have replicas.");
654+
}
655+
656+
var rls = e.Services.GetRequiredService<ResourceLoggerService>();
657+
var resourceLogger = rls.GetLogger(builder.Resource);
658+
resourceLogger.LogInformation($"Waiting for resource '{dependency.Resource.Name}' to complete.");
659+
660+
var rns = e.Services.GetRequiredService<ResourceNotificationService>();
661+
await rns.PublishUpdateAsync(builder.Resource, s => s with { State = KnownResourceStates.Waiting }).ConfigureAwait(false);
662+
var resourceEvent = await rns.WaitForResourceAsync(dependency.Resource.Name, re => IsKnownTerminalState(re.Snapshot), cancellationToken: ct).ConfigureAwait(false);
663+
var snapshot = resourceEvent.Snapshot;
664+
665+
if (snapshot.State == KnownResourceStates.FailedToStart)
666+
{
667+
resourceLogger.LogError(
668+
"Dependency resource '{ResourceName}' failed to start.",
669+
dependency.Resource.Name
670+
);
671+
672+
throw new DistributedApplicationException($"Dependency resource '{dependency.Resource.Name}' failed to start.");
673+
}
674+
else if ((snapshot.State!.Text == KnownResourceStates.Finished || snapshot.State!.Text == KnownResourceStates.Exited) && snapshot.ExitCode != exitCode)
675+
{
676+
resourceLogger.LogError(
677+
"Resource '{ResourceName}' has entered the '{State}' state with exit code '{ExitCode}'",
678+
dependency.Resource.Name,
679+
snapshot.State.Text,
680+
snapshot.ExitCode
681+
);
682+
683+
throw new DistributedApplicationException(
684+
$"Resource '{dependency.Resource.Name}' has entered the '{snapshot.State.Text}' state with exit code '{snapshot.ExitCode}'"
685+
);
686+
}
687+
});
688+
689+
return builder;
690+
691+
static bool IsKnownTerminalState(CustomResourceSnapshot snapshot) =>
692+
KnownResourceStates.TerminalStates.Contains(snapshot.State?.Text) &&
693+
snapshot.ExitCode is not null;
694+
}
552695
}

0 commit comments

Comments
 (0)