From 712c992ca827576c05923e6a134ca0bec87af4df Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 18 Sep 2017 12:55:54 -0700 Subject: [PATCH] Add BackgroundService, a base class for long running HostedServices (#1215) * Add BackgroundService, a base class for long running HostedServices - Today the IHostedService pattern is a StartAsync/StopAsync pattern. Neither of these methods are supposed to return a long running task that represents an execution. If you wanted to have some logic run on a timer every 5 minutes, it's unnatural to do so with simple async idioms. This base class implements IHostedService and exposes a pattern where a long running async Task can be returned. - The token passed into ExecuteAsync represents the lifetime of the execution. - StartAsync and StopAsync were made virtual to allow the derived type to indicate Start failures. - Added tests --- samples/GenericHostSample/MyServiceA.cs | 30 +-- .../BackgroundService.cs | 75 ++++++++ .../BackgroundHostedServiceTests.cs | 171 ++++++++++++++++++ 3 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 src/Microsoft.Extensions.Hosting.Abstractions/BackgroundService.cs create mode 100644 test/Microsoft.Extensions.Hosting.Tests/BackgroundHostedServiceTests.cs diff --git a/samples/GenericHostSample/MyServiceA.cs b/samples/GenericHostSample/MyServiceA.cs index 93be796bbb3..d8f8c6f039d 100644 --- a/samples/GenericHostSample/MyServiceA.cs +++ b/samples/GenericHostSample/MyServiceA.cs @@ -5,38 +5,22 @@ namespace GenericHostSample { - public class MyServiceA : IHostedService + public class MyServiceA : BackgroundService { - private bool _stopping; - private Task _backgroundTask; - - public Task StartAsync(CancellationToken cancellationToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { Console.WriteLine("MyServiceA is starting."); - _backgroundTask = BackgroundTask(); - return Task.CompletedTask; - } - private async Task BackgroundTask() - { - while (!_stopping) + stoppingToken.Register(() => Console.WriteLine("MyServiceA is stopping.")); + + while (!stoppingToken.IsCancellationRequested) { - await Task.Delay(TimeSpan.FromSeconds(5)); Console.WriteLine("MyServiceA is doing background work."); + + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } Console.WriteLine("MyServiceA background task is stopping."); } - - public async Task StopAsync(CancellationToken cancellationToken) - { - Console.WriteLine("MyServiceA is stopping."); - _stopping = true; - if (_backgroundTask != null) - { - // TODO: cancellation - await _backgroundTask; - } - } } } diff --git a/src/Microsoft.Extensions.Hosting.Abstractions/BackgroundService.cs b/src/Microsoft.Extensions.Hosting.Abstractions/BackgroundService.cs new file mode 100644 index 00000000000..65b2bf7e848 --- /dev/null +++ b/src/Microsoft.Extensions.Hosting.Abstractions/BackgroundService.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Hosting +{ + /// + /// Base class for implementing a long running . + /// + public abstract class BackgroundService : IHostedService, IDisposable + { + private Task _executingTask; + private readonly CancellationTokenSource _stoppingCts = new CancellationTokenSource(); + + /// + /// This method is called when the starts. The implementation should return a task that represents + /// the lifetime of the long running operation(s) being performed. + /// + /// Triggered when is called. + /// A that represents the long running operations. + protected abstract Task ExecuteAsync(CancellationToken stoppingToken); + + /// + /// Triggered when the application host is ready to start the service. + /// + /// Indicates that the start process has been aborted. + public virtual Task StartAsync(CancellationToken cancellationToken) + { + // Store the task we're executing + _executingTask = ExecuteAsync(_stoppingCts.Token); + + // If the task is completed then return it, this will bubble cancellation and failure to the caller + if (_executingTask.IsCompleted) + { + return _executingTask; + } + + // Otherwise it's running + return Task.CompletedTask; + } + + /// + /// Triggered when the application host is performing a graceful shutdown. + /// + /// Indicates that the shutdown process should no longer be graceful. + public virtual async Task StopAsync(CancellationToken cancellationToken) + { + // Stop called without start + if (_executingTask == null) + { + return; + } + + try + { + // Signal cancellation to the executing method + _stoppingCts.Cancel(); + } + finally + { + // Wait until the task completes or the stop token triggers + await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken)); + } + + } + + public virtual void Dispose() + { + _stoppingCts.Cancel(); + } + } +} diff --git a/test/Microsoft.Extensions.Hosting.Tests/BackgroundHostedServiceTests.cs b/test/Microsoft.Extensions.Hosting.Tests/BackgroundHostedServiceTests.cs new file mode 100644 index 00000000000..40b537fafb7 --- /dev/null +++ b/test/Microsoft.Extensions.Hosting.Tests/BackgroundHostedServiceTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.Hosting.Tests +{ + public class BackgroundHostedServiceTests + { + [Fact] + public void StartReturnsCompletedTaskIfLongRunningTaskIsIncomplete() + { + var tcs = new TaskCompletionSource(); + var service = new MyBackgroundService(tcs.Task); + + var task = service.StartAsync(CancellationToken.None); + + Assert.True(task.IsCompleted); + Assert.False(tcs.Task.IsCompleted); + + // Complete the tsk + tcs.TrySetResult(null); + } + + [Fact] + public void StartReturnsCompletedTaskIfCancelled() + { + var tcs = new TaskCompletionSource(); + tcs.TrySetCanceled(); + var service = new MyBackgroundService(tcs.Task); + + var task = service.StartAsync(CancellationToken.None); + + Assert.True(task.IsCompleted); + } + + [Fact] + public async Task StartReturnsLongRunningTaskIfFailed() + { + var tcs = new TaskCompletionSource(); + tcs.TrySetException(new Exception("fail!")); + var service = new MyBackgroundService(tcs.Task); + + var exception = await Assert.ThrowsAsync(() => service.StartAsync(CancellationToken.None)); + + Assert.Equal("fail!", exception.Message); + } + + [Fact] + public async Task StopAsyncWithoutStartAsyncNoops() + { + var tcs = new TaskCompletionSource(); + var service = new MyBackgroundService(tcs.Task); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task StopAsyncStopsBackgroundService() + { + var tcs = new TaskCompletionSource(); + var service = new MyBackgroundService(tcs.Task); + + await service.StartAsync(CancellationToken.None); + + Assert.False(service.ExecuteTask.IsCompleted); + + await service.StopAsync(CancellationToken.None); + + Assert.True(service.ExecuteTask.IsCompleted); + } + + [Fact] + public async Task StopAsyncStopsEvenIfTaskNeverEnds() + { + var service = new IgnoreCancellationService(); + + await service.StartAsync(CancellationToken.None); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await service.StopAsync(cts.Token); + } + + [Fact] + public async Task StopAsyncThrowsIfCancellationCallbackThrows() + { + var service = new ThrowOnCancellationService(); + + await service.StartAsync(CancellationToken.None); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await Assert.ThrowsAsync(() => service.StopAsync(cts.Token)); + + Assert.Equal(2, service.TokenCalls); + } + + [Fact] + public async Task StartAsyncThenDisposeTriggersCancelledToken() + { + var service = new WaitForCancelledTokenService(); + + await service.StartAsync(CancellationToken.None); + + service.Dispose(); + } + + private class WaitForCancelledTokenService : BackgroundService + { + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + return Task.Delay(Timeout.Infinite, stoppingToken); + } + } + + private class ThrowOnCancellationService : BackgroundService + { + public int TokenCalls { get; set; } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + stoppingToken.Register(() => + { + TokenCalls++; + throw new InvalidOperationException(); + }); + + stoppingToken.Register(() => + { + TokenCalls++; + }); + + return new TaskCompletionSource().Task; + } + } + + private class IgnoreCancellationService : BackgroundService + { + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + return new TaskCompletionSource().Task; + } + } + + private class MyBackgroundService : BackgroundService + { + private readonly Task _task; + + public Task ExecuteTask { get; set; } + + public MyBackgroundService(Task task) + { + _task = task; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + ExecuteTask = ExecuteCore(stoppingToken); + await ExecuteTask; + } + + private async Task ExecuteCore(CancellationToken stoppingToken) + { + var task = await Task.WhenAny(_task, Task.Delay(Timeout.Infinite, stoppingToken)); + + await task; + } + } + } +}