offers to start and stop IHostedService concurrently (#84048)
authorBadre BSAILA <54767641+pedrobsaila@users.noreply.github.com>
Tue, 11 Apr 2023 20:10:50 +0000 (22:10 +0200)
committerGitHub <noreply@github.com>
Tue, 11 Apr 2023 20:10:50 +0000 (13:10 -0700)
* offers to start and stop IHostedService concurrently

src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs
src/libraries/Microsoft.Extensions.Hosting/src/HostOptions.cs
src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs
src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs
src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs
src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/DelegateHostedService.cs [new file with mode: 0644]
src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs
src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs

index 085bc47..d8820aa 100644 (file)
@@ -105,6 +105,8 @@ namespace Microsoft.Extensions.Hosting
     {
         public HostOptions() { }
         public Microsoft.Extensions.Hosting.BackgroundServiceExceptionBehavior BackgroundServiceExceptionBehavior { get { throw null; } set { } }
+        public bool ServicesStartConcurrently { get { throw null; } set { } }
+        public bool ServicesStopConcurrently { get { throw null; } set { } }
         public System.TimeSpan ShutdownTimeout { get { throw null; } set { } }
     }
 }
index ac7cc3f..c6c29dc 100644 (file)
@@ -18,6 +18,16 @@ namespace Microsoft.Extensions.Hosting
         public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(30);
 
         /// <summary>
+        /// Determines if the <see cref="IHost"/> will start registered instances of <see cref="IHostedService"/> concurrently or sequentially. Defaults to false.
+        /// </summary>
+        public bool ServicesStartConcurrently { get; set; }
+
+        /// <summary>
+        /// Determines if the <see cref="IHost"/> will stop registered instances of <see cref="IHostedService"/> concurrently or sequentially. Defaults to false.
+        /// </summary>
+        public bool ServicesStopConcurrently { get; set; }
+
+        /// <summary>
         /// The behavior the <see cref="IHost"/> will follow when any of
         /// its <see cref="BackgroundService"/> instances throw an unhandled exception.
         /// </summary>
@@ -35,6 +45,20 @@ namespace Microsoft.Extensions.Hosting
             {
                 ShutdownTimeout = TimeSpan.FromSeconds(seconds);
             }
+
+            var servicesStartConcurrently = configuration["servicesStartConcurrently"];
+            if (!string.IsNullOrEmpty(servicesStartConcurrently)
+                && bool.TryParse(servicesStartConcurrently, out bool startBehavior))
+            {
+                ServicesStartConcurrently = startBehavior;
+            }
+
+            var servicesStopConcurrently = configuration["servicesStopConcurrently"];
+            if (!string.IsNullOrEmpty(servicesStopConcurrently)
+                && bool.TryParse(servicesStopConcurrently, out bool stopBehavior))
+            {
+                ServicesStopConcurrently = stopBehavior;
+            }
         }
     }
 }
index 33dcc55..fcc55b2 100644 (file)
@@ -4,6 +4,7 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using System.Runtime.ExceptionServices;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.Extensions.DependencyInjection;
@@ -65,14 +66,65 @@ namespace Microsoft.Extensions.Hosting.Internal
             combinedCancellationToken.ThrowIfCancellationRequested();
             _hostedServices = Services.GetRequiredService<IEnumerable<IHostedService>>();
 
-            foreach (IHostedService hostedService in _hostedServices)
+            List<Exception> exceptions = new List<Exception>();
+
+            if (_options.ServicesStartConcurrently)
             {
-                // Fire IHostedService.Start
-                await hostedService.StartAsync(combinedCancellationToken).ConfigureAwait(false);
+                Task tasks = Task.WhenAll(_hostedServices.Select(async service =>
+                {
+                    await service.StartAsync(combinedCancellationToken).ConfigureAwait(false);
+
+                    if (service is BackgroundService backgroundService)
+                    {
+                        _ = TryExecuteBackgroundServiceAsync(backgroundService);
+                    }
+                }));
 
-                if (hostedService is BackgroundService backgroundService)
+                try
                 {
-                    _ = TryExecuteBackgroundServiceAsync(backgroundService);
+                    await tasks.ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    exceptions.AddRange(tasks.Exception?.InnerExceptions ?? new[] { ex }.AsEnumerable());
+                }
+            }
+            else
+            {
+                foreach (IHostedService hostedService in _hostedServices)
+                {
+                    try
+                    {
+                        // Fire IHostedService.Start
+                        await hostedService.StartAsync(combinedCancellationToken).ConfigureAwait(false);
+
+                        if (hostedService is BackgroundService backgroundService)
+                        {
+                            _ = TryExecuteBackgroundServiceAsync(backgroundService);
+                        }
+                    }
+                    catch (Exception ex)
+                    {
+                        exceptions.Add(ex);
+                        break;
+                    }
+                }
+            }
+
+            if (exceptions.Count > 0)
+            {
+                if (exceptions.Count == 1)
+                {
+                    // Rethrow if it's a single error
+                    Exception singleException = exceptions[0];
+                    _logger.HostedServiceStartupFaulted(singleException);
+                    ExceptionDispatchInfo.Capture(singleException).Throw();
+                }
+                else
+                {
+                    var ex = new AggregateException("One or more hosted services failed to start.", exceptions);
+                    _logger.HostedServiceStartupFaulted(ex);
+                    throw ex;
                 }
             }
 
@@ -128,15 +180,34 @@ namespace Microsoft.Extensions.Hosting.Internal
                 var exceptions = new List<Exception>();
                 if (_hostedServices != null) // Started?
                 {
-                    foreach (IHostedService hostedService in _hostedServices.Reverse())
+                    // Ensure hosted services are stopped in LIFO order
+                    IEnumerable<IHostedService> hostedServices = _hostedServices.Reverse();
+
+                    if (_options.ServicesStopConcurrently)
                     {
+                        Task tasks = Task.WhenAll(hostedServices.Select(async service => await service.StopAsync(token).ConfigureAwait(false)));
+
                         try
                         {
-                            await hostedService.StopAsync(token).ConfigureAwait(false);
+                            await tasks.ConfigureAwait(false);
                         }
                         catch (Exception ex)
                         {
-                            exceptions.Add(ex);
+                            exceptions.AddRange(tasks.Exception?.InnerExceptions ?? new[] { ex }.AsEnumerable());
+                        }
+                    }
+                    else
+                    {
+                        foreach (IHostedService hostedService in hostedServices)
+                        {
+                            try
+                            {
+                                await hostedService.StopAsync(token).ConfigureAwait(false);
+                            }
+                            catch (Exception ex)
+                            {
+                                exceptions.Add(ex);
+                            }
                         }
                     }
                 }
@@ -155,9 +226,19 @@ namespace Microsoft.Extensions.Hosting.Internal
 
                 if (exceptions.Count > 0)
                 {
-                    var ex = new AggregateException("One or more hosted services failed to stop.", exceptions);
-                    _logger.StoppedWithException(ex);
-                    throw ex;
+                    if (exceptions.Count == 1)
+                    {
+                        // Rethrow if it's a single error
+                        Exception singleException = exceptions[0];
+                        _logger.StoppedWithException(singleException);
+                        ExceptionDispatchInfo.Capture(singleException).Throw();
+                    }
+                    else
+                    {
+                        var ex = new AggregateException("One or more hosted services failed to stop.", exceptions);
+                        _logger.StoppedWithException(ex);
+                        throw ex;
+                    }
                 }
             }
 
index fc4daf7..71354c6 100644 (file)
@@ -101,5 +101,16 @@ namespace Microsoft.Extensions.Hosting.Internal
                     message: SR.BackgroundServiceExceptionStoppedHost);
             }
         }
+
+        public static void HostedServiceStartupFaulted(this ILogger logger, Exception? ex)
+        {
+            if (logger.IsEnabled(LogLevel.Error))
+            {
+                logger.LogError(
+                   eventId: LoggerEventIds.HostedServiceStartupFaulted,
+                   exception: ex,
+                   message: "Hosting failed to start");
+            }
+        }
     }
 }
index 9271953..64666cd 100644 (file)
@@ -17,5 +17,6 @@ namespace Microsoft.Extensions.Hosting.Internal
         public static readonly EventId ApplicationStoppedException = new EventId(8, nameof(ApplicationStoppedException));
         public static readonly EventId BackgroundServiceFaulted = new EventId(9, nameof(BackgroundServiceFaulted));
         public static readonly EventId BackgroundServiceStoppingHost = new EventId(10, nameof(BackgroundServiceStoppingHost));
+        public static readonly EventId HostedServiceStartupFaulted = new EventId(11, nameof(HostedServiceStartupFaulted));
     }
 }
diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/DelegateHostedService.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/DelegateHostedService.cs
new file mode 100644 (file)
index 0000000..6f6b319
--- /dev/null
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Extensions.Hosting.Unit.Tests;
+
+internal class DelegateHostedService : IHostedService, IDisposable
+{
+    private readonly Action _started;
+    private readonly Action _stopping;
+    private readonly Action _disposing;
+
+    public DelegateHostedService(Action started, Action stopping, Action disposing)
+    {
+        _started = started;
+        _stopping = stopping;
+        _disposing = disposing;
+    }
+
+    public Task StartAsync(CancellationToken token)
+    {
+        StartDate = DateTimeOffset.Now;
+        _started();
+        return Task.CompletedTask;
+    }
+    public Task StopAsync(CancellationToken token)
+    {
+        StopDate = DateTimeOffset.Now;
+        _stopping();
+        return Task.CompletedTask;
+    }
+
+    public void Dispose() => _disposing();
+
+    public DateTimeOffset StartDate { get; private set; }
+    public DateTimeOffset StopDate { get; private set; }
+}
index eef367f..4b678b8 100644 (file)
@@ -9,13 +9,13 @@ using System.Diagnostics.Tracing;
 using System.IO;
 using System.Linq;
 using System.Reflection;
-using System.Runtime.InteropServices;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.DotNet.RemoteExecutor;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Configuration.UserSecrets;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting.Unit.Tests;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Options;
 using Xunit;
@@ -36,6 +36,77 @@ namespace Microsoft.Extensions.Hosting.Tests
             await host.StopAsync(cts.Token);
         }
 
+        public static IEnumerable<object[]> StartAsync_StopAsync_Concurrency_TestCases
+        {
+            get
+            {
+                foreach (bool stopConcurrently in new[] { true, false })
+                {
+                    foreach (bool startConcurrently in new[] { true, false })
+                    {
+                        foreach (int hostedServiceCount in new[] { 1, 4, 10 })
+                        {
+                            yield return new object[] { stopConcurrently, startConcurrently, hostedServiceCount };
+                        }
+                    }
+                }
+            }
+        }
+
+        [Theory]
+        [MemberData(nameof(StartAsync_StopAsync_Concurrency_TestCases))]
+        public async Task StartAsync_StopAsync_Concurrency(bool stopConcurrently, bool startConcurrently, int hostedServiceCount)
+        {
+            var hostedServices = new DelegateHostedService[hostedServiceCount];
+            bool[,] events = new bool[hostedServiceCount, 2];
+
+            for (int i = 0; i < hostedServiceCount; i++)
+            {
+                var index = i;
+                var service = new DelegateHostedService(() => { events[index, 0] = true; }, () => { events[index, 1] = true; } , () => { });
+
+                hostedServices[index] = service;
+            }
+
+            using var host = Host.CreateDefaultBuilder().ConfigureHostConfiguration(configBuilder =>
+            {
+                configBuilder.AddInMemoryCollection(new KeyValuePair<string, string>[]
+                {
+                    new KeyValuePair<string, string>("servicesStartConcurrently", startConcurrently.ToString()),
+                    new KeyValuePair<string, string>("servicesStopConcurrently", stopConcurrently.ToString())
+                });
+            }).ConfigureServices(serviceCollection =>
+            {
+                foreach (var hostedService in hostedServices)
+                {
+                    serviceCollection.Add(ServiceDescriptor.Singleton<IHostedService>(hostedService));
+                }
+            }).Build();
+
+            await host.StartAsync(CancellationToken.None);
+
+            // Verifies that StartAsync had been called and that StopAsync had not been launched yet
+            for (int i = 0; i < hostedServiceCount; i++)
+            {
+                Assert.True(events[i, 0]);
+                Assert.False(events[i, 1]);
+            }
+
+            // Ensures that IHostedService instances are started in FIFO order
+            AssertExtensions.CollectionEqual(hostedServices, hostedServices.OrderBy(h => h.StartDate), EqualityComparer<DelegateHostedService>.Default);
+
+            await host.StopAsync(CancellationToken.None);
+
+            // Verifies that StopAsync had been called
+            for (int i = 0; i < hostedServiceCount; i++)
+            {
+                Assert.True(events[i, 1]);
+            }
+
+            // Ensures that IHostedService instances are stopped in LIFO order
+            AssertExtensions.CollectionEqual(hostedServices.Reverse(), hostedServices.OrderBy(h => h.StopDate), EqualityComparer<DelegateHostedService>.Default);
+        }
+
         [Fact]
         public void CreateDefaultBuilder_IncludesContentRootByDefault()
         {
index d55df71..23cf84e 100644 (file)
@@ -4,7 +4,6 @@
 using System;
 using System.Collections.Generic;
 using System.Diagnostics;
-using System.Diagnostics.Tracing;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
@@ -13,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting.Fakes;
 using Microsoft.Extensions.Hosting.Tests;
 using Microsoft.Extensions.Hosting.Tests.Fakes;
+using Microsoft.Extensions.Hosting.Unit.Tests;
 using Microsoft.Extensions.Logging;
 using Moq;
 using Xunit;
@@ -217,27 +217,54 @@ namespace Microsoft.Extensions.Hosting.Internal
             }
         }
 
-        [Fact]
-        public async Task AppCrashesOnStartWhenFirstHostedServiceThrows()
+        [Theory]
+        [InlineData(1, true), InlineData(1, false)]
+        [InlineData(2, true), InlineData(2, false)]
+        [InlineData(10, true), InlineData(10, false)]
+        public async Task AppCrashesOnStartWhenFirstHostedServiceThrows(int eventCount, bool concurrentStartup)
         {
-            bool[] events1 = null;
-            bool[] events2 = null;
+            bool[][] events = new bool[eventCount][];
 
             using (var host = CreateBuilder()
-                .ConfigureServices((services) =>
+                .ConfigureServices(services =>
                 {
-                    events1 = RegisterCallbacksThatThrow(services);
-                    events2 = RegisterCallbacksThatThrow(services);
+                    services.Configure<HostOptions>(i => i.ServicesStartConcurrently = concurrentStartup);
+
+                    for (int i = 0; i < eventCount; i++)
+                    {
+                        events[i] = RegisterCallbacksThatThrow(services);
+                    }
                 })
                 .Build())
             {
-                await Assert.ThrowsAsync<InvalidOperationException>(() => host.StartAsync());
-                Assert.True(events1[0]);
-                Assert.False(events2[0]);
+                if (concurrentStartup && eventCount > 1)
+                {
+                    await Assert.ThrowsAsync<AggregateException>(() => host.StartAsync());
+                }
+                else
+                {
+                    await Assert.ThrowsAsync<InvalidOperationException>(() => host.StartAsync());
+                }
+
+                for (int i = 0; i < eventCount; i++)
+                {
+                    if (i == 0 || concurrentStartup)
+                    {
+                        Assert.True(events[i][0]);
+                    }
+                    else
+                    {
+                        Assert.False(events[i][0]);
+                    }
+                }
+
                 host.Dispose();
+
                 // Stopping
-                Assert.False(events1[1]);
-                Assert.False(events2[1]);
+                for (int i = 0; i < eventCount; i++)
+                {
+                    Assert.False(events[i][1]);
+                }
             }
         }
 
@@ -1108,32 +1135,60 @@ namespace Microsoft.Extensions.Hosting.Internal
             }
         }
 
-        [Fact]
-        public async Task HostDoesNotNotifyIHostApplicationLifetimeCallbacksIfIHostedServicesThrow()
+        [Theory]
+        [InlineData(1, true), InlineData(1, false)]
+        [InlineData(2, true), InlineData(2, false)]
+        [InlineData(10, true), InlineData(10, false)]
+        public async Task HostDoesNotNotifyIHostApplicationLifetimeCallbacksIfIHostedServicesThrow(int eventCount, bool concurrentStartup)
         {
-            bool[] events1 = null;
-            bool[] events2 = null;
+            bool[][] events = new bool[eventCount][];
 
             using (var host = CreateBuilder()
                 .ConfigureServices((services) =>
                 {
-                    events1 = RegisterCallbacksThatThrow(services);
-                    events2 = RegisterCallbacksThatThrow(services);
+                    services.Configure<HostOptions>(i => i.ServicesStartConcurrently = concurrentStartup);
+
+                    for (int i = 0; i < eventCount; i++)
+                    {
+                        events[i] = RegisterCallbacksThatThrow(services);
+                    }
                 })
                 .Build())
             {
                 var applicationLifetime = host.Services.GetService<IHostApplicationLifetime>();
-
                 var started = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStarted);
                 var stopping = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStopping);
 
-                await Assert.ThrowsAsync<InvalidOperationException>(() => host.StartAsync());
-                Assert.True(events1[0]);
-                Assert.False(events2[0]);
+                if (concurrentStartup && eventCount > 1)
+                {
+                    await Assert.ThrowsAsync<AggregateException>(() => host.StartAsync());
+                }
+                else
+                {
+                    await Assert.ThrowsAsync<InvalidOperationException>(() => host.StartAsync());
+                }
+
+                for (int i = 0; i < eventCount; i++)
+                {
+                    if (i == 0 || concurrentStartup)
+                    {
+                        Assert.True(events[i][0]);
+                    }
+                    else
+                    {
+                        Assert.False(events[i][0]);
+                    }
+                }
+
                 Assert.False(started.All(s => s));
+
                 host.Dispose();
-                Assert.False(events1[1]);
-                Assert.False(events2[1]);
+
+                for (int i = 0; i < eventCount; i++)
+                {
+                    Assert.False(events[i][1]);
+                }
+
                 Assert.False(stopping.All(s => s));
             }
         }
@@ -1504,33 +1559,6 @@ namespace Microsoft.Extensions.Hosting.Internal
             }
         }
 
-        private class DelegateHostedService : IHostedService, IDisposable
-        {
-            private readonly Action _started;
-            private readonly Action _stopping;
-            private readonly Action _disposing;
-
-            public DelegateHostedService(Action started, Action stopping, Action disposing)
-            {
-                _started = started;
-                _stopping = stopping;
-                _disposing = disposing;
-            }
-
-            public Task StartAsync(CancellationToken token)
-            {
-                _started();
-                return Task.CompletedTask;
-            }
-            public Task StopAsync(CancellationToken token)
-            {
-                _stopping();
-                return Task.CompletedTask;
-            }
-
-            public void Dispose() => _disposing();
-        }
-
         private class AsyncDisposableService : IAsyncDisposable
         {
             public bool DisposeAsyncCalled { get; set; }