-
Notifications
You must be signed in to change notification settings - Fork 4.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[API Proposal]: Add IHostedLifecycleService
to support additional callbacks
#86511
Comments
Tagging subscribers to this area: @dotnet/area-extensions-dependencyinjection Issue DetailsBackground and motivationIn order to support scenarios that need a hook point when starting a Host, this is cumbersome today and only supports the hosting model. This proposal:
See also:
A prototype is located here where the unit tests verify a similar R9 callback approach and also verify that the validation effort done in V6 could be implemented with this. API Proposalnamespace Microsoft.Extensions.DependencyInjection;
public partial interface IServiceStartup
{
System.Threading.Tasks.Task StartupAsync(System.Threading.CancellationToken cancellationToken);
} API UsageSee the referenced prototype above. Alternative DesignsNo response RisksNo response
|
I think it would be useful to have some top-level built in timeout. Somebody could implement this interface as something that does not return, and then the server would stuck without any diagnostic about the reason. |
An implementation of It looks like the existing host implementation has the same issue with the implementation not returning (i.e. no timeout). If so, then I don't think we would want to add one just the new startup services. Perhaps for both |
This shouldn't be part of DI if isn't used in the DI container |
Below is the runtime layering: graph TD;
Logging-->DependencyInjection;
Logging-->Logging.Abstractions;
Logging-->DependencyInjection.Abstractions;
Logging-->Options;
Hosting-->Logging;
Hosting-->Logging.EventLog;
Hosting-->Logging.Debug;
Hosting-->FileProviders.Physical;
Hosting-->Logging.Configuration;
Hosting-->Configuration;
Hosting-->DependencyInjection;
Hosting-->Configuration.Json;
Hosting-->Configuration.Binder;
Hosting-->Configuration.EnvironmentVariables;
Hosting-->Logging.EventSource;
Hosting-->Configuration.FileExtensions;
Hosting-->Logging.Abstractions;
Hosting-->Hosting.Abstractions;
Hosting-->Configuration.Abstractions;
Hosting-->DependencyInjection.Abstractions;
Hosting-->FileProviders.Abstractions;
Hosting-->Options;
Hosting-->Logging.Console;
Hosting-->Configuration.UserSecrets;
Hosting-->Configuration.CommandLine;
Logging.EventLog-->Logging;
Logging.EventLog-->Logging.Abstractions;
Logging.EventLog-->DependencyInjection.Abstractions;
Logging.EventLog-->Options;
Logging.Debug-->Logging;
Logging.Debug-->Logging.Abstractions;
Logging.Debug-->DependencyInjection.Abstractions;
Configuration.Ini-->Configuration;
Configuration.Ini-->Configuration.FileExtensions;
Configuration.Ini-->Configuration.Abstractions;
Configuration.Ini-->FileProviders.Abstractions;
Logging.TraceSource-->Logging;
Logging.TraceSource-->Logging.Abstractions;
Logging.TraceSource-->DependencyInjection.Abstractions;
FileProviders.Physical-->FileSystemGlobbing;
FileProviders.Physical-->Primitives;
FileProviders.Physical-->FileProviders.Abstractions;
Configuration.Xml-->Configuration;
Configuration.Xml-->Configuration.FileExtensions;
Configuration.Xml-->Configuration.Abstractions;
Configuration.Xml-->FileProviders.Abstractions;
Logging.Configuration-->Logging;
Logging.Configuration-->Configuration;
Logging.Configuration-->Configuration.Binder;
Logging.Configuration-->Options.ConfigurationExtensions;
Logging.Configuration-->Logging.Abstractions;
Logging.Configuration-->Configuration.Abstractions;
Logging.Configuration-->DependencyInjection.Abstractions;
Logging.Configuration-->Options;
Configuration-->Primitives;
Configuration-->Configuration.Abstractions;
DependencyInjection-->DependencyInjection.Abstractions;
Configuration.Json-->Configuration;
Configuration.Json-->Configuration.FileExtensions;
Configuration.Json-->Configuration.Abstractions;
Configuration.Json-->FileProviders.Abstractions;
Http-->Logging;
Http-->Logging.Abstractions;
Http-->DependencyInjection.Abstractions;
Http-->Options;
Configuration.Binder-->Configuration.Abstractions;
Configuration.EnvironmentVariables-->Configuration;
Configuration.EnvironmentVariables-->Configuration.Abstractions;
Logging.EventSource-->Logging;
Logging.EventSource-->Primitives;
Logging.EventSource-->Logging.Abstractions;
Logging.EventSource-->DependencyInjection.Abstractions;
Logging.EventSource-->Options;
Configuration.FileExtensions-->FileProviders.Physical;
Configuration.FileExtensions-->Configuration;
Configuration.FileExtensions-->Primitives;
Configuration.FileExtensions-->Configuration.Abstractions;
Configuration.FileExtensions-->FileProviders.Abstractions;
Options.ConfigurationExtensions-->Configuration.Binder;
Options.ConfigurationExtensions-->Primitives;
Options.ConfigurationExtensions-->Configuration.Abstractions;
Options.ConfigurationExtensions-->DependencyInjection.Abstractions;
Options.ConfigurationExtensions-->Options;
Options.DataAnnotations-->DependencyInjection.Abstractions;
Options.DataAnnotations-->Options;
Caching.Abstractions-->Primitives;
Hosting.Abstractions:::classHA-->Configuration.Abstractions;
Hosting.Abstractions-->DependencyInjection.Abstractions;
Hosting.Abstractions-->FileProviders.Abstractions;
Configuration.Abstractions-->Primitives;
FileProviders.Abstractions-->Primitives;
Options:::classO-->Primitives;
Options-->DependencyInjection.Abstractions;
Logging.Console-->Logging;
Logging.Console-->Logging.Configuration;
Logging.Console-->Options.ConfigurationExtensions;
Logging.Console-->Logging.Abstractions;
Logging.Console-->Configuration.Abstractions;
Logging.Console-->DependencyInjection.Abstractions;
Logging.Console-->Options;
Configuration.UserSecrets-->FileProviders.Physical;
Configuration.UserSecrets-->Configuration.Json;
Configuration.UserSecrets-->Configuration.Abstractions;
Caching.Memory-->Primitives;
Caching.Memory-->Logging.Abstractions;
Caching.Memory-->Caching.Abstractions;
Caching.Memory-->DependencyInjection.Abstractions;
Caching.Memory-->Options;
Configuration.CommandLine-->Configuration;
Configuration.CommandLine-->Configuration.Abstractions;
classDef classHA fill:#f96
classDef classO fill:#9f3
This would mean that Options would add We'd also need to think about #43149. This would need to go in @davidfowl / @DamianEdwards, what do you think about timeout? Is it OK to add a timeout that corresponds to both this and IHostedService startup? |
This could only be a cooperative timeout right? So if we did have one, I would assume it would be encapsulated in the |
Moving to |
It should work the same way as today's ShutdownTimeout + Host.StopAsync() which uses the cancellation token. Also, the startup timeout would need to be off by default, to prevent a breaking change for long-running |
Are we saying it will stop "blocking" startup after the timeout is reached? |
Not sure what is being "blocked" here. The idea of the timeout is to help diagnose any slow implementations of the new Whether we extend the timeout to the existing
If we do option (2) and the timeout is hit, I assume the The flow for option (1) when
|
IServiceStartup.StartupAsync()
to support pre-startup scenariosIHostedServiceStartup.StartupAsync()
to support pre-startup scenarios
If we were to do this as part of DI, we would really need for it to be in all the DI implementations, not just MEDI. We would want Given that it takes a long time to add DI features, and we can do this outside of DI, I think this proposal makes sense. Unless we really think that this must be part of DI. @davidfowl - how passionate are you about making this a DI feature? (Interestingly enough, this issue is in
I kind of think this should be a separate proposal. We have |
Tagging subscribers to this area: @dotnet/area-extensions-hosting Issue DetailsBackground and motivationIn order to support scenarios that need a hook point when starting a Host that runs before the existing hook point. This proposal:
See also:
A prototype is located here. API ProposalThese located in the Microsoft.Extensions.Hosting.Abstractions assembly.namespace Microsoft.Extensions.Hosting
{
+ public partial interface IHostedServiceStartup
+ {
+ Task StartupAsync(CancellationToken cancellationToken = default(System.Threading.CancellationToken));
+ }
}
// For consistency with other IServiceCollection extensions, use the DependencyInjection namspace
namespace Microsoft.Extensions.DependencyInjection
{
public static class ServiceCollectionHostedServiceExtensions
{
// Callback specifying a delegate.
+ public static IServiceCollection AddServiceStartup(
+ this IServiceCollection services,
+ System.Func<IServiceProvider, CancellationToken, Task> startupTask);
// Callback specifying an implementation class.
+ public static ServiceCollection AddServiceStartup
+ <[DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes.PublicConstructors)] TServiceStartup>
+ (this IServiceCollection services)
+ where TServiceStartup : class, IHostedServiceStartup;
}
} 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 both IHostedServiceStartup.StartupAsync() and IHostedService.StartAsync().
// The timeout is off by default, unlike ShutdownTimeout which is 30 seconds. This avoids a breaking change
// since IHostedService.StartAsync() is included in the timeout.
// We use TimeSpan.Zero to signify no timeout; these semantics will also be applied to ShutdownTimeout for consistency.
+ public TimeSpan StartupTimeout { get; set; } = TimeSpan.Zero;
}
} API UsageSee the referenced prototype above. Example of using a delegate: IHostBuilder hostBuilder = CreateHostBuilder(services =>
{
services.AddServiceStartup((provider, cancellationToken) =>
{
// <add logic here>
return Task.CompletedTask;
});
});
using (IHost host = hostBuilder.Build())
{
await host.StartAsync();
} Alternative DesignsNo response RisksNo response
|
I don't think anyone was suggesting to put the call to the startup logic in DI. Merely put the interface there, since it would avoid introducing new dependencies. Just putting the interface in DI.Abstractions doesn't take a long time, though it does "feel wrong" as @davidfowl previously mentioned.
This feature is meant to completely replace https://github.com/dotnet/extensions/tree/main/src/ToBeMoved/Hosting.StartupInitialization which did have a timeout. So while I agree they could be separate, addressing the timeout question for startup methods is an important one for completeness to remove the |
Waiting on validation from @tekian or substitute to verify this proposal will remove the need for workaround logic linked above |
@steveharter Proposal looks good, thank you. Placing this under |
Note to cloud native reviewers @tekian @rafal-mz and ASP.NET reviewers @DamianEdwards @davidfowl this is marked "ready for review" and will be reviewed soon (earliest would be June 8th). |
Just to clarify this:
Applications using |
Could we do this without the new The big upside of this is that the In the API usage example, |
I added one reason I don't like that approach here.
I don't consider that a "big" upside. While we don't explicitly say this scenario is unsupported, it is pretty typical behavior that if you don't update to the new assemblies, you don't get new behavior. |
Completely agree, hooks upon hooks leads to this:
If you really want ordering then design a startup pipeline like we have for middleware. |
I don't think the cost of keeping around the no-op IHostedService after startup would be too high. If you implement it yourself, you can unreferenced anything you want to after start completes. If you use the |
@halter73 sounds like you're giving implementation feedback. Even with an interface it could be plugged into an The problem with IHostedService (now) is that there is no way to guarantee ordering. You can try to insert the service in front https://github.com/dotnet/extensions/blob/f4952b69a04e9bef266089cbc3059675693fbaa0/src/ToBeMoved/Hosting.StartupInitialization/Internal/StartupInitializationBuilder.cs#L67-L69 but the user might still specify concurrent start: #84048 If we're really worried about the hosting mismatch we could insert a sentinel |
Then let's make a way. Trying to special case stuff with additional stages is unsustainable. How about this: + public interface IOrderedHostedService : IHostedService
+ {
+ int Order { get; }
+ }
Alternatively, what about a singleton service designed to sort the Or we go back to the explicit ordering model: First added wins. As long as the user has control over the order things are added, it should be ok. |
I think of it more as a bifurcation and not just ordering control in a single list. For example, scenarios when framework code needs to always come before (or after) application code. For this feature, the "framework code" is the new callback here which for cloud native needs to run before existing application code which may not even know or care about the framework code. That's why there's effectively 2 lists here. Ordering for a single (simple) list won't work if HostOptions.ServicesStartConcurrently is + public interface INonConcurrentHostedService
+ { // Here, just a signature; overrides ServicesStartConcurrently=true
+ } which may also be useful for the 2-list case as well to address cases where validation, for example, needs to finish before other startup logic (singleton preheating, for example).
It should really be a fixed value; I don't think we want to worry about the value changing or have to re-sort on every use for example. But it could be made to work. In general, having the application code specify order of the "known" services (as today) works fine; it's the newly-desired framework-added services that need special treatment. |
I think that the problem is that currently we don't have clear point when application is ready to boot. I understand Use caces for IStartupService:
With such distinction in mind, hosted service implementations may assume that app configuration is correct. |
namespace Microsoft.Extensions.Hosting
{
public interface IHostedServiceStartup
{
Task StartupAsync(CancellationToken cancellationToken = default);
}
}
// For consistency with other IServiceCollection extensions, use the DependencyInjection namespace
namespace Microsoft.Extensions.DependencyInjection
{
public static partial class ServiceCollectionHostedServiceExtensions
{
public static ServiceCollection AddServiceStartup
<[DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes.PublicConstructors)] TServiceStartup>(
this IServiceCollection services)
where TServiceStartup : class, IHostedServiceStartup;
public static IServiceCollection AddServiceStartup(
this IServiceCollection services,
Func<IServiceProvider, CancellationToken, Task> startupFunc);
}
} |
It seems like these examples are themselves order dependent, so the new interface doesn't solve the ordering problem, it just subdivides it. Ultimately, you'll end up with ordering dependencies between these scenarios that we'll have to solve. Solving the broader problem of how to order IHostedServices would address that now. |
IHostedServiceStartup.StartupAsync()
to support pre-startup scenariosIHostedLifecycleService
to support pre-startup scenarios
IHostedLifecycleService
to support pre-startup scenariosIHostedLifecycleService
to support additional callbacks
Yes this doesn't solve the "inner dependency" issue. However, if the dependencies are known ahead-of-time here's some strategies:
If the dependencies are not known ahead-of-time, such as framework not knowing about application code, or by having two extensions that don't know about each other, then the logical ordering layering here is one more tool to help with that. |
namespace Microsoft.Extensions.Hosting;
public interface IHostedLifecycleService : IHostedService
{
Task StartingAsync(CancellationToken cancellationToken);
Task StartedAsync(CancellationToken cancellationToken);
Task StoppingAsync(CancellationToken cancellationToken);
Task StoppedAsync(CancellationToken cancellationToken);
}
public partial class HostOptions
{
public TimeSpan StartupTimeout { get; set; }
} |
Background and motivation
In order to support scenarios that need hook points before and after
IHostedService.StartAsync()
andStopAsync()
, this proposal adds a new interfaceIHostedLifecycleService
with these hooks points. It derives from IHostedService and is not injected separately fromIHostedService
.This interface is located in the
Microsoft.Extensions.Hosting.Abstractions
assembly which will be supported by the default host (in theMicrosoft.Extensions.Hosting
assembly). Other hosts will need to implementIHostedService
in order to support this new interface.See also:
API Proposal
These located in the Microsoft.Extensions.Hosting.Abstractions assembly.
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
Design notes
IHostedService.StartAsync()
andIHostedService.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:
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:
IHostedLifecycleService.StartingAsync()
, runs serially with otherIHostedLifecycleService.StartingAsync()
implementations in the order of registration until anawait
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 returnTask.CompletedTask
if the implementation is a noop. This also allows the author more control over the ordering and semantics of additional async calls.Timeouts
StartAsync()
especially in cases whereStartAsync()
is the actual long-running service logic.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 ofIHostedLifecycleService
: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.
Risks
No response
The text was updated successfully, but these errors were encountered: