-
Notifications
You must be signed in to change notification settings - Fork 47
Add cancellation token support #46
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
base: master
Are you sure you want to change the base?
Changes from 4 commits
5eda310
df88b69
12f4e86
6667077
6bd6112
7a3b047
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,9 @@ | |
| using System.Linq; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| using FluentAssertions; | ||
| using Gofer.NET.Utils; | ||
|
|
||
| using Xunit; | ||
|
|
||
| namespace Gofer.NET.Tests | ||
|
|
@@ -15,11 +16,11 @@ public class GivenATaskClient | |
| public async Task ItContinuesListeningWhenATaskThrowsAnException() | ||
| { | ||
| var waitTime = 5000; | ||
|
|
||
| var taskQueue = TaskQueueTestFixture.UniqueRedisTaskQueue(); | ||
| var taskClient = new TaskClient(taskQueue); | ||
| var semaphoreFile = Path.GetTempFileName(); | ||
|
|
||
| await taskClient.TaskQueue.Enqueue(() => Throw()); | ||
| await taskClient.TaskQueue.Enqueue(() => TaskQueueTestFixture.WriteSemaphore(semaphoreFile)); | ||
|
|
||
|
|
@@ -28,37 +29,59 @@ public async Task ItContinuesListeningWhenATaskThrowsAnException() | |
|
|
||
| taskClient.CancelListen(); | ||
| await task; | ||
|
|
||
|
|
||
| TaskQueueTestFixture.EnsureSemaphore(semaphoreFile); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task ItStopsOnCancellation() | ||
| { | ||
| var semaphoreFile = Path.GetTempFileName(); | ||
|
|
||
| var waitTime = 2000; | ||
|
|
||
| var taskQueue = TaskQueueTestFixture.UniqueRedisTaskQueue(); | ||
| var taskClient = new TaskClient(taskQueue); | ||
| var cancellation = new CancellationTokenSource(); | ||
|
|
||
| await taskClient.TaskQueue.Enqueue(() => TaskQueueTestFixture.WaitForTaskClientCancellationAndWriteSemaphore(semaphoreFile)); | ||
|
|
||
| var task = Task.Run(async () => await taskClient.Listen(cancellation.Token), CancellationToken.None); | ||
| await Task.Delay(waitTime, CancellationToken.None); | ||
| cancellation.Cancel(); | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enqueued task (WaitForTaskClientCancellationAndWriteSemaphore) will be canceled at this moment. |
||
| await Task.Delay(waitTime, CancellationToken.None); | ||
| await task; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a bug is introduced, and the task fails to exit on cancellation will this introduce a hanging test? |
||
|
|
||
| TaskQueueTestFixture.EnsureSemaphore(semaphoreFile); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task ItDoesNotDelayScheduledTaskPromotionWhenRunningLongTasks() | ||
| { | ||
| var waitTime = 4000; | ||
|
|
||
| var taskQueue = TaskQueueTestFixture.UniqueRedisTaskQueue(); | ||
| var taskClient = new TaskClient(taskQueue); | ||
|
|
||
| var semaphoreFile = Path.GetTempFileName(); | ||
| File.Delete(semaphoreFile); | ||
| File.Exists(semaphoreFile).Should().BeFalse(); | ||
|
|
||
| await taskClient.TaskQueue.Enqueue(() => Wait(waitTime)); | ||
|
|
||
| await taskClient.TaskScheduler.AddScheduledTask( | ||
| () => TaskQueueTestFixture.WriteSemaphore(semaphoreFile), | ||
| TimeSpan.FromMilliseconds(waitTime / 4)); | ||
|
|
||
| var task = Task.Run(async () => await taskClient.Listen()); | ||
|
|
||
| await Task.Delay(waitTime / 2); | ||
|
|
||
| // Ensure we did not run the scheduled task | ||
| File.Exists(semaphoreFile).Should().BeFalse(); | ||
|
|
||
| var dequeuedScheduledTask = await taskQueue.Dequeue(); | ||
|
|
||
| File.Exists(semaphoreFile).Should().BeFalse(); | ||
| dequeuedScheduledTask.Should().NotBeNull(); | ||
| dequeuedScheduledTask.MethodName.Should().Be(nameof(TaskQueueTestFixture.WriteSemaphore)); | ||
|
|
@@ -83,19 +106,19 @@ public async Task ItExecutesImmediateAndScheduledTasksInOrder() | |
| File.Delete(semaphoreFile); | ||
| File.Exists(semaphoreFile).Should().BeFalse(); | ||
|
|
||
| for (var i=0; i<immediateTasks; ++i) | ||
| for (var i = 0; i < immediateTasks; ++i) | ||
| { | ||
| await taskClient.TaskQueue.Enqueue(() => | ||
| TaskQueueTestFixture.WriteSemaphoreValue(semaphoreFile, (i+1).ToString())); | ||
| await taskClient.TaskQueue.Enqueue(() => | ||
| TaskQueueTestFixture.WriteSemaphoreValue(semaphoreFile, (i + 1).ToString())); | ||
| } | ||
|
|
||
| for (var i=0; i<scheduledTasks; ++i) | ||
| for (var i = 0; i < scheduledTasks; ++i) | ||
| { | ||
| await taskClient.TaskScheduler.AddScheduledTask( | ||
| () => TaskQueueTestFixture.WriteSemaphoreValue(semaphoreFile, (immediateTasks+i+1).ToString()), | ||
| TimeSpan.FromMilliseconds(scheduledTasksStart + (scheduledTasksIncrement*i))); | ||
| () => TaskQueueTestFixture.WriteSemaphoreValue(semaphoreFile, (immediateTasks + i + 1).ToString()), | ||
| TimeSpan.FromMilliseconds(scheduledTasksStart + (scheduledTasksIncrement * i))); | ||
| } | ||
|
|
||
| var task = Task.Run(async () => await taskClient.Listen()); | ||
| Thread.Sleep(scheduledTasks * scheduledTasksIncrement + 2000); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,37 +2,38 @@ | |
| using System.IO; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| using FluentAssertions; | ||
|
|
||
| namespace Gofer.NET.Tests | ||
| { | ||
| public class TaskQueueTestFixture | ||
| { | ||
| private static readonly ReaderWriterLock Locker = new ReaderWriterLock(); | ||
|
|
||
| public static string SemaphoreText => "completed"; | ||
|
|
||
| public TaskQueue TaskQueue { get; } | ||
|
|
||
| public static string RedisConnectionString => "localhost:6379"; | ||
|
|
||
| private readonly string _semaphoreFile; | ||
|
|
||
| public static TaskQueue UniqueRedisTaskQueue(string prefix=null) | ||
| public static TaskQueue UniqueRedisTaskQueue(string prefix = null) | ||
| { | ||
| var taskQueueName = $"{prefix ?? nameof(TaskQueueTestFixture)}::{Guid.NewGuid().ToString()}"; | ||
| return TaskQueue.Redis(RedisConnectionString, taskQueueName); | ||
| } | ||
|
|
||
| public TaskQueueTestFixture(string uniqueId, TaskQueue taskQueue=null) | ||
| public TaskQueueTestFixture(string uniqueId, TaskQueue taskQueue = null) | ||
| { | ||
| _semaphoreFile = Path.Combine(AppContext.BaseDirectory, uniqueId, Path.GetTempFileName()); | ||
|
|
||
| var testQueueName = uniqueId + "::TestQueue"; | ||
| TaskQueue = taskQueue ?? TaskQueueTestFixture.UniqueRedisTaskQueue(uniqueId); | ||
|
|
||
| // Clear out the queue | ||
| while(TaskQueue.Dequeue().Result != null) { } | ||
| while (TaskQueue.Dequeue().Result != null) { } | ||
| } | ||
|
|
||
| public async Task PushPopExecuteWriteSemaphore() | ||
|
|
@@ -52,12 +53,12 @@ public void EnsureSemaphore() | |
| { | ||
| EnsureSemaphore(_semaphoreFile); | ||
| } | ||
|
|
||
| public static void EnsureSemaphore(string semaphoreFile) | ||
| { | ||
| try | ||
| { | ||
| Locker.AcquireReaderLock(30000); | ||
| Locker.AcquireReaderLock(30000); | ||
| File.ReadAllText(semaphoreFile).Should().Be(SemaphoreText); | ||
| } | ||
| finally | ||
|
|
@@ -70,12 +71,27 @@ public static void WriteSemaphore(string semaphoreFile) | |
| { | ||
| WriteSemaphoreValue(semaphoreFile, SemaphoreText); | ||
| } | ||
|
|
||
|
|
||
| public static async Task WaitForTaskClientCancellationAndWriteSemaphore(string semaphoreFile) | ||
| { | ||
| var token = TaskClient.GetListenCancellation(); | ||
| if (!token.CanBeCanceled) | ||
| throw new InvalidOperationException("This method must be called from a task client callback"); | ||
| try | ||
| { | ||
| await Task.Delay(-1, token); | ||
|
||
| } | ||
| catch (OperationCanceledException) | ||
| { | ||
| } | ||
| WriteSemaphore(semaphoreFile); | ||
| } | ||
|
|
||
| public static void WriteSemaphoreValue(string semaphoreFile, object value) | ||
| { | ||
| try | ||
| { | ||
| Locker.AcquireWriterLock(30000); | ||
| Locker.AcquireWriterLock(30000); | ||
| File.AppendAllText(semaphoreFile, value?.ToString() ?? "null"); | ||
| } | ||
| finally | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,22 @@ | ||
| using System; | ||
| using System.ComponentModel; | ||
| using System.Diagnostics; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Gofer.NET.Errors; | ||
|
|
||
| using Gofer.NET.Utils; | ||
| using Newtonsoft.Json; | ||
|
|
||
| namespace Gofer.NET | ||
| { | ||
| public class TaskClient | ||
| { | ||
| private static readonly AsyncLocal<CancellationToken> _listenCancellationContext = new AsyncLocal<CancellationToken>(); | ||
|
|
||
| private static readonly object Locker = new object(); | ||
|
|
||
| private const int PollDelay = 100; | ||
|
|
||
| private bool IsCanceled { get; set; } | ||
| public static CancellationToken GetListenCancellation() => _listenCancellationContext.Value; | ||
|
|
||
| public TaskQueue TaskQueue { get; } | ||
|
|
||
| public Action<Exception> OnError { get; } | ||
|
|
@@ -29,35 +30,46 @@ public class TaskClient | |
| private CancellationTokenSource ListenCancellationTokenSource { get; set; } | ||
|
|
||
| public TaskClient( | ||
| TaskQueue taskQueue, | ||
| Action<Exception> onError=null) | ||
| TaskQueue taskQueue, | ||
| Action<Exception> onError = null) | ||
| { | ||
| TaskQueue = taskQueue; | ||
| OnError = onError; | ||
| TaskScheduler = new TaskScheduler(TaskQueue); | ||
| IsCanceled = false; | ||
| } | ||
|
|
||
| public async Task Listen() | ||
| public Task Listen() | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To clean up the mix of being able to pass in a cancellation token here and at the same time have a default cancellation token source I recommend the following:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Current implementation has a very low overhead so I'd prefer to keep it until there are some scenarios it does not fit nicely. Official docs recommend to pass CancellationToken, not CancellationTokenSource. Linked cancellation token is a standard approach for supporting both external and internal cancellation. So there is nothing unusual and surprising for end-user code. |
||
| { | ||
| return Listen(CancellationToken.None); | ||
| } | ||
|
|
||
| public async Task Listen(CancellationToken cancellation) | ||
| { | ||
| Start(); | ||
| Start(cancellation); | ||
|
|
||
| await Task.WhenAll(new [] { | ||
| TaskRunnerThread, | ||
| await Task.WhenAll(new[] { | ||
| TaskRunnerThread, | ||
| TaskSchedulerThread}); | ||
| } | ||
|
|
||
| public CancellationTokenSource Start() | ||
| { | ||
| return Start(CancellationToken.None); | ||
| } | ||
|
|
||
| public CancellationTokenSource Start(CancellationToken cancellation) | ||
| { | ||
| if (TaskSchedulerThread != null || TaskRunnerThread != null) | ||
| { | ||
| throw new Exception("This TaskClient is already listening."); | ||
| } | ||
|
|
||
| ListenCancellationTokenSource = new CancellationTokenSource(); | ||
|
|
||
| ListenCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellation); | ||
| var token = ListenCancellationTokenSource.Token; | ||
|
|
||
| TaskSchedulerThread = Task.Run(async () => { | ||
| TaskSchedulerThread = Task.Run(async () => | ||
| { | ||
| var inThreadTaskScheduler = new TaskScheduler(TaskQueue); | ||
|
|
||
| while (true) | ||
|
|
@@ -69,43 +81,49 @@ public CancellationTokenSource Start() | |
|
|
||
| await inThreadTaskScheduler.Tick(); | ||
| } | ||
| }, ListenCancellationTokenSource.Token); | ||
| }, token); | ||
|
|
||
| TaskRunnerThread = Task.Run(async () => { | ||
| TaskRunnerThread = Task.Run(async () => | ||
| { | ||
| while (true) | ||
| { | ||
| if (token.IsCancellationRequested) | ||
| { | ||
| return; | ||
| } | ||
| await ExecuteQueuedTask(); | ||
|
|
||
| await ExecuteQueuedTask(token); | ||
| } | ||
| }, ListenCancellationTokenSource.Token); | ||
| }, token); | ||
|
|
||
| return ListenCancellationTokenSource; | ||
| } | ||
|
|
||
| private async Task ExecuteQueuedTask() | ||
| private async Task ExecuteQueuedTask(CancellationToken token) | ||
| { | ||
| var (json, info) = await TaskQueue.SafeDequeue(); | ||
| if (info != null) | ||
| { | ||
| LogTaskStarted(info); | ||
|
|
||
| var old = _listenCancellationContext.Value; | ||
| try | ||
| { | ||
| var now = DateTime.Now; | ||
|
|
||
| _listenCancellationContext.Value = token; | ||
|
|
||
| var executionTimer = Stopwatch.StartNew(); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| await info.ExecuteTask(); | ||
|
|
||
| var completionSeconds = (DateTime.Now - now).TotalSeconds; | ||
| executionTimer.Stop(); | ||
| var completionSeconds = executionTimer.Elapsed.TotalSeconds; | ||
| LogTaskFinished(info, completionSeconds); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| LogTaskException(info, e); | ||
| } | ||
| finally | ||
| { | ||
| _listenCancellationContext.Value = old; | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -122,7 +140,7 @@ private void LogTaskStarted(TaskInfo info) | |
| var logMessage = Messages.TaskStarted(info); | ||
| ThreadSafeColoredConsole.Info(logMessage); | ||
| } | ||
|
|
||
| private void LogTaskFinished(TaskInfo info, double completionSeconds) | ||
| { | ||
| var logMessage = Messages.TaskFinished(info, completionSeconds); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like a bug in the test, good catch!