Skip to content

[API Proposal]: Add IHostedLifecycleService to support additional callbacks #86511

Closed
@steveharter

Description

@steveharter

Background and motivation

In order to support scenarios that need hook points before and after IHostedService.StartAsync() and StopAsync(), this proposal adds a new interface IHostedLifecycleService with these hooks points. It derives from IHostedService and is not injected separately from IHostedService.

This interface is located in the Microsoft.Extensions.Hosting.Abstractions assembly which will be supported by the default host (in the Microsoft.Extensions.Hosting assembly). Other hosts will need to implement IHostedService in order to support this new interface.

See also:

API Proposal

These located in the Microsoft.Extensions.Hosting.Abstractions assembly.

namespace Microsoft.Extensions.Hosting
{
+    public interface IHostedLifecycleService : IHostedService
+    {
+        // These are awaited before 'IHostedService.StartAsync()' are run.
+        Task StartingAsync(CancellationToken cancellationToken);

+        // These are awaited after 'IHostedService.StartAsync()' are run.
+        Task StartedAsync(CancellationToken cancellationToken);

+        // These are awaited before 'IHostedService.StopAsync()' are run.
+        Task StoppingAsync(CancellationToken cancellationToken);

+        // These are awaited after 'IHostedService.StopAsync()' is run.
+        Task StoppedAsync(CancellationToken cancellationToken);
+    }
}

These located in the Microsoft.Extensions.Hosting assembly:

namespace Microsoft.Extensions.Hosting
{
    public class HostOptions
    {
        // Similar to ShutdownTimeout used in Host.StopAsync(), this creates a CancellationToken with the timeout.
        // Applies to Host.StartAsync() encompassing IHostedLifecycleService.StartingAsync(), IHostedService.StartAsync()
        // and IHostedLifecycleService.StartedAsync().
        // The timeout is off by default, unlike ShutdownTimeout which is 30 seconds.
        // This avoids a breaking change since existing implementations of IHostedService.StartAsync() are included in the timeout.
+       public TimeSpan StartupTimeout { get; set; } = Timeout.InfiniteTimeSpan;
    }
}

API Usage

IHostBuilder hostBuilder = new HostBuilder();
hostBuilder.ConfigureServices(services =>
{
    services.AddHostedService<MyService>();
}

using (IHost host = hostBuilder.Build())
{
    await host.StartAsync();
}

public class MyService : IHostedLifecycleService
{
    public Task StartingAsync(CancellationToken cancellationToken) => /* add logic here */ Task.CompletedTask;
    public Task StartAsync(CancellationToken cancellationToken) => /* add logic here */ Task.CompletedTask;
    public Task StartedAsync(CancellationToken cancellationToken) => /* add logic here */ Task.CompletedTask;
    public Task StopAsync(CancellationToken cancellationToken) => /* add logic here */ Task.CompletedTask;
    public Task StoppedAsync(CancellationToken cancellationToken) => /* add logic here */ Task.CompletedTask;
    public Task StoppingAsync(CancellationToken cancellationToken) => /* add logic here */ Task.CompletedTask;
}

Design notes

  • The intent is that the new callbacks are cooperative amongst users and only used for services such as validation and warm-up scenarios that need to occur before\after the existing IHostedService.StartAsync() and IHostedService.StopAsync().

Ordering

The existing IHostApplicationLifetime which can be used for essentially the same hook points for "Started", "Stopping" and "Stopped" (but not "Starting") although those run serially and do not support an async, Task-based model. The new hook points contain more local semantics and thus are run before the corresponding IHostApplicationLifetime ones which are more "global" and may want to post-process any changes made. The IHostLifetime callbacks always come first\last.

The full lifecycle:

  • IHostLifetime.WaitForStartAsync
  • IHostedLifecycleService.StartingAsync
  • IHostedService.Start
  • IHostedLifecycleService.StartedAsync
  • IHostApplicationLifetime.ApplicationStarted
  • IHostedLifecycleService.StoppingAsync
  • IHostApplicationLifetime.ApplicationStopping
  • IHostedService.Stop
  • IHostedLifecycleService.StoppedAsync
  • IHostApplicationLifetime.ApplicationStopped
  • IHostLifetime.StopAsync

Exceptions and guarantees

Exception semantics are basically the same: exceptions from callbacks are caught, and when all callbacks are called, the exception(s) are logged and re-thrown.

All callbacks are guaranteed to be called (minus special cases in shutdown). For backwards compat, this means that for the default host at least, once start is called, all handlers for starting, start and stopped will be called even if an exception occurs in one of the earlier phases. The same applies for stop.

The above semantics do not hold for exception thrown from IHostApplicationLifetime callbacks which log but do not re-throw.

Threading

The newer options HostOptions.ServicesStartConcurrently and HostOptions.ServicesStopConcurrently added in V8 support an opt-in for a concurrent mode which runs both the StartAsync and StopAsync callbacks concurrently plus the new callbacks. When the newer concurrent mode is not enabled, the callbacks run serially.

When the concurrent mode is enabled:

  • A given hook point, such as a IHostedLifecycleService.StartingAsync(), runs serially with other IHostedLifecycleService.StartingAsync() implementations in the order of registration until an await occurs, if any (or in general, an uncompleted Task is returned). At that point, all such async callbacks are run concurrently. This is to optimize performance since many hook point will return Task.CompletedTask if the implementation is a noop. This also allows the author more control over the ordering and semantics of additional async calls.
  • The StartAsync and\or StopAsync will change to the same optimized serial+concurrent model as explained above for the new callbacks**. Currently StartAsync\StopAsync do not have the "serial" portion and thus run concurrently even for synchronous methods. Changing this is feasible because the main reason for this new V8 feature was to avoid timeouts during a long shutdown, such as when the host needs to drain requests, and that logic should be async already.

Timeouts

Alternative Designs

An optional feature for ease-of-use for adding a simple callback via func\delegate (instead of overriding all 6 methods when only 1 is needed) was prototyped but no longer considered for v8 because it would have inconsistent and potentially confusing concurrency, exception and ordering semantics that are different from implementing IHostedLifecycleService directly.

The implementation would likely use a single instance of IHostedLifecycleService with a chained delegate for each callback type (starting, started, stopping, stopped and potentially start+stop) with the semantics differing from using an implementation of IHostedLifecycleService:

  • Async but running in series (instead of concurrent for callbacks that aren't all sync)
  • The first exception will prevent subsequent delegates from being called (instead of guaranteeing each callback is called for a given method)
  • Ordering is relative to other implementations of IHostedLifecycleService by the first call to add any delegate and not the individual order (a "builder" pattern would be useful to communicate that).

To get consistent semantics with direct IHostedLifecycleService implementations requires new public APIs so the host can know about this pattern -- the prototype simply used the abstractions assembly, and the hosting assembly was unaware of this.

E.g.

// For consistency with other IServiceCollection extensions, use the DependencyInjection namspace
namespace Microsoft.Extensions.DependencyInjection
{
    public static class ServiceCollectionHostedServiceExtensions
    {
        // Helper callbacks specifying a delegate for ease-of-use:

+       public static IServiceCollection AddServiceStarting(
+           this IServiceCollection services,
+           System.Func<IServiceProvider, CancellationToken, Task> startingFunc);

+       public static IServiceCollection AddServiceStart(
+           this IServiceCollection services,
+           System.Func<IServiceProvider, CancellationToken, Task> startingFunc);

+       public static IServiceCollection AddServiceStarted(
+           this IServiceCollection services,
+           System.Func<IServiceProvider, CancellationToken, Task> startedFunc);

+       public static IServiceCollection AddServiceStopping(
+           this IServiceCollection services,
+           System.Func<IServiceProvider, CancellationToken, Task> stoppingFunc);

+       public static IServiceCollection AddServiceStop(
+           this IServiceCollection services,
+           System.Func<IServiceProvider, CancellationToken, Task> startingFunc);

+       public static IServiceCollection AddServiceStopped(
+           this IServiceCollection services,
+           System.Func<IServiceProvider, CancellationToken, Task> stoppedFunc);
    }
}

Risks

No response

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-Extensions-HostingblockingMarks issues that we want to fast track in order to unblock other important work

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions