Skip to content

Commit 0016ddb

Browse files
CopilotdavidfowlCopilotkarolz-ms
authored
Add ResourceStoppedEvent with ResourceEvent state for enhanced lifecycle management (#11103)
* Initial plan * Implement ResourceStoppedEvent and OnResourceStopped extension method Co-authored-by: davidfowl <[email protected]> * Add ResourceStoppedEvent test and fix implementation Co-authored-by: davidfowl <[email protected]> * Refactor ResourceStoppedEvent tests for improved clarity and structure * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Refactor resource event publishing to include hierarchy support and improve clarity * Fix incorrect comment in PublishEventToHierarchy method Co-authored-by: karolz-ms <[email protected]> * Add ResourceEvent parameter to ResourceStoppedEvent and update ApplicationOrchestrator to get current state Co-authored-by: davidfowl <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: davidfowl <[email protected]> Co-authored-by: David Fowler <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: karolz-ms <[email protected]>
1 parent ad05b1c commit 0016ddb

File tree

4 files changed

+164
-6
lines changed

4 files changed

+164
-6
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.Eventing;
5+
6+
namespace Aspire.Hosting.ApplicationModel;
7+
8+
/// <summary>
9+
/// This event is raised after a resource has stopped.
10+
/// </summary>
11+
/// <param name="resource">The resource that has stopped.</param>
12+
/// <param name="services">The <see cref="IServiceProvider"/> for the app host.</param>
13+
/// <param name="resourceEvent">The <see cref="ResourceEvent"/> containing the current state information.</param>
14+
/// <remarks>
15+
/// This event allows for cleanup or unregistration logic when a resource is stopped by an orchestrator.
16+
/// </remarks>
17+
public class ResourceStoppedEvent(IResource resource, IServiceProvider services, ResourceEvent resourceEvent) : IDistributedApplicationResourceEvent
18+
{
19+
/// <inheritdoc />
20+
public IResource Resource { get; } = resource;
21+
22+
/// <summary>
23+
/// The <see cref="IServiceProvider"/> for the app host.
24+
/// </summary>
25+
public IServiceProvider Services { get; } = services;
26+
27+
/// <summary>
28+
/// The <see cref="ResourceEvent"/> containing the current state information.
29+
/// </summary>
30+
public ResourceEvent ResourceEvent { get; } = resourceEvent;
31+
}

src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ public static IResourceBuilder<T> OnBeforeResourceStarted<T>(this IResourceBuild
2222
where T : IResource
2323
=> builder.OnEvent(callback);
2424

25+
/// <summary>
26+
/// Subscribes a callback to the <see cref="ResourceStoppedEvent"/> event within the AppHost.
27+
/// </summary>
28+
/// <typeparam name="T">The resource type.</typeparam>
29+
/// <param name="builder">The resource builder.</param>
30+
/// <param name="callback">A callback to handle the event.</param>
31+
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
32+
public static IResourceBuilder<T> OnResourceStopped<T>(this IResourceBuilder<T> builder, Func<T, ResourceStoppedEvent, CancellationToken, Task> callback)
33+
where T : IResource
34+
=> builder.OnEvent(callback);
35+
2536
/// <summary>
2637
/// Subscribes a callback to the <see cref="ConnectionStringAvailableEvent"/> event within the AppHost.
2738
/// </summary>

src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,11 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT
221221
urls.Add(url);
222222

223223
// In the case that a service is bound to multiple addresses or a *.localhost address, we generate
224-
// additional URLs to indicate to the user other ways their service can be reached. If the service
225-
// is bound to all interfaces (0.0.0.0, ::, etc.) we use the machine name as the additional
226-
// address. If bound to a *.localhost address, we add the originally declared *.localhost address
227-
// as an additional URL.
228-
var additionalUrl = allocatedEndpoint.BindingMode switch
224+
// additional URLs to indicate to the user other ways their service can be reached. If the service
225+
// is bound to all interfaces (0.0.0.0, ::, etc.) we use the machine name as the additional
226+
// address. If bound to a *.localhost address, we add the originally declared *.localhost address
227+
// as an additional URL.
228+
var additionalUrl = allocatedEndpoint.BindingMode switch
229229
{
230230
// The allocated address doesn't match the original target host, so include the target host as
231231
// an additional URL.
@@ -306,12 +306,35 @@ private async Task OnResourceEndpointsAllocated(ResourceEndpointsAllocatedEvent
306306

307307
private async Task OnResourceChanged(OnResourceChangedContext context)
308308
{
309+
// Get the previous state before updating to detect transitions to stopped states
310+
string? previousState = null;
311+
if (_notificationService.TryGetCurrentState(context.DcpResourceName, out var previousResourceEvent))
312+
{
313+
previousState = previousResourceEvent.Snapshot.State?.Text;
314+
}
315+
309316
await _notificationService.PublishUpdateAsync(context.Resource, context.DcpResourceName, context.UpdateSnapshot).ConfigureAwait(false);
310317

311318
if (context.ResourceType == KnownResourceTypes.Container)
312319
{
313320
await SetChildResourceAsync(context.Resource, context.Status.State, context.Status.StartupTimestamp, context.Status.FinishedTimestamp).ConfigureAwait(false);
314321
}
322+
323+
// Check if the resource has transitioned to a terminal/stopped state
324+
var currentState = context.Status.State;
325+
if (currentState is not null &&
326+
KnownResourceStates.TerminalStates.Contains(currentState) &&
327+
previousState != currentState &&
328+
(previousState is null ||
329+
!KnownResourceStates.TerminalStates.Contains(previousState)))
330+
{
331+
// Get the current state from notification service after the update
332+
if (_notificationService.TryGetCurrentState(context.DcpResourceName, out var currentResourceEvent))
333+
{
334+
// Resource has transitioned from a non-terminal state to a terminal state - fire ResourceStoppedEvent
335+
await PublishEventToHierarchy(r => new ResourceStoppedEvent(r, _serviceProvider, currentResourceEvent), context.Resource, context.CancellationToken).ConfigureAwait(false);
336+
}
337+
}
315338
}
316339

317340
private async Task OnResourceFailedToStart(OnResourceFailedToStartContext context)
@@ -466,4 +489,20 @@ private async Task PublishConnectionStringAvailableEvent(IResource resource, Can
466489
}
467490
}
468491
}
492+
493+
private async Task PublishEventToHierarchy<TEvent>(Func<IResource, TEvent> createEvent, IResource resource, CancellationToken cancellationToken)
494+
where TEvent : IDistributedApplicationResourceEvent
495+
{
496+
// Publish the event to the resource itself.
497+
await _eventing.PublishAsync(createEvent(resource), cancellationToken).ConfigureAwait(false);
498+
499+
// Publish the event to all child resources.
500+
if (_parentChildLookup[resource] is { } children)
501+
{
502+
foreach (var child in children.Where(c => c is IResourceWithParent))
503+
{
504+
await PublishEventToHierarchy(createEvent, child, cancellationToken).ConfigureAwait(false);
505+
}
506+
}
507+
}
469508
}

tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Aspire.Hosting.Utils;
77
using Microsoft.AspNetCore.InternalTesting;
88
using Microsoft.Extensions.DependencyInjection;
9+
using Aspire.Hosting.Dashboard;
910

1011
namespace Aspire.Hosting.Tests.Eventing;
1112

@@ -177,7 +178,7 @@ public async Task ResourceEventsForContainersFireForSpecificResources()
177178

178179
using var builder = TestDistributedApplicationBuilder.Create();
179180
var redis = builder.AddRedis("redis")
180-
.OnBeforeResourceStarted((_, e, _) =>
181+
.OnBeforeResourceStarted((_, e, _) =>
181182
{
182183
Assert.NotNull(e.Services);
183184
Assert.NotNull(e.Resource);
@@ -265,7 +266,83 @@ public async Task LifeycleHookAnalogousEventsFire()
265266
await app.StopAsync();
266267
}
267268

269+
[Fact]
270+
public async Task ResourceStoppedEventCanBeSubscribedTo()
271+
{
272+
var eventFired = false;
273+
var resourceStopped = default(IResource);
274+
275+
using var builder = TestDistributedApplicationBuilder.Create();
276+
var resource = builder.AddResource(new TestResource("test-resource"))
277+
.OnResourceStopped((res, evt, ct) =>
278+
{
279+
eventFired = true;
280+
resourceStopped = res;
281+
Assert.NotNull(evt.Services);
282+
Assert.Equal(res, evt.Resource);
283+
Assert.NotNull(evt.ResourceEvent);
284+
Assert.Equal(res, evt.ResourceEvent.Resource);
285+
return Task.CompletedTask;
286+
});
287+
288+
// Verify the subscription was registered (the event handler is stored in the eventing service)
289+
Assert.NotNull(resource);
290+
291+
// This test focuses on verifying subscription registration and callback structure.
292+
// The following integration test handles actual event firing with complex setup.
293+
using var app = builder.Build();
294+
var eventing = app.Services.GetRequiredService<IDistributedApplicationEventing>();
295+
296+
// Manually fire the event to test the subscription
297+
var testSnapshot = new CustomResourceSnapshot
298+
{
299+
ResourceType = "TestResource",
300+
Properties = []
301+
};
302+
var testResourceEvent = new ResourceEvent(resource.Resource, "test-resource", testSnapshot);
303+
var testEvent = new ResourceStoppedEvent(resource.Resource, app.Services, testResourceEvent);
304+
await eventing.PublishAsync(testEvent, CancellationToken.None);
305+
306+
Assert.True(eventFired);
307+
Assert.Equal(resource.Resource, resourceStopped);
308+
}
309+
310+
[Fact]
311+
[RequiresDocker]
312+
public async Task ResourceStoppedEventFiresWhenResourceStops()
313+
{
314+
var resourceStoppedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
315+
316+
using var builder = TestDistributedApplicationBuilder.Create();
317+
var redis = builder.AddRedis("redis")
318+
.OnResourceStopped((resource, e, _) =>
319+
{
320+
Assert.NotNull(e.Services);
321+
Assert.NotNull(e.Resource);
322+
Assert.Equal(resource, e.Resource);
323+
resourceStoppedTcs.TrySetResult();
324+
return Task.CompletedTask;
325+
});
326+
327+
using var app = builder.Build();
328+
await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestTimeout);
329+
330+
// Get the resource notification service to wait for the resource to start
331+
await app.ResourceNotifications.WaitForResourceAsync("redis", KnownResourceStates.Running).DefaultTimeout();
332+
333+
await app.ResourceCommands.ExecuteCommandAsync("redis", KnownResourceCommands.StopCommand);
334+
335+
// Verify that ResourceStoppedEvent was fired
336+
await resourceStoppedTcs.Task.DefaultTimeout();
337+
}
338+
268339
public class DummyEvent : IDistributedApplicationEvent
269340
{
270341
}
342+
343+
private sealed class TestResource(string name) : IResource
344+
{
345+
public string Name { get; } = name;
346+
public ResourceAnnotationCollection Annotations { get; } = new();
347+
}
271348
}

0 commit comments

Comments
 (0)