Skip to content
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

Add cancellation token support #46

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

ig-sinicyn
Copy link

@ig-sinicyn ig-sinicyn commented Jul 19, 2020

This PR

  1. Adds cancellation token support for TaskClient Listen()/Start() methods.
  2. Adds cancellation support for enqueued tasks.
    • I've added JsonSkipCancellationTokenConverter to ensure same logic will be applied even if the TaskInfo is created manually
    • The InvokeMethod() method accepts CancellationToken arg and replaces target method cancellation arg with the passed token.

Tests included.


var task = Task.Run(async () => await taskClient.Listen(cancellation.Token), CancellationToken.None);
await Task.Delay(waitTime, CancellationToken.None);
cancellation.Cancel();
Copy link
Author

@ig-sinicyn ig-sinicyn Jul 19, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enqueued task (WaitForTaskClientCancellationAndWriteSemaphore) will be canceled at this moment.

@ig-sinicyn
Copy link
Author

up

@brthor
Copy link
Owner

brthor commented Jul 24, 2020

Thank you for including tests with your PR. I am starting to look over it.

@@ -28,37 +29,59 @@ public async Task ItContinuesListeningWhenATaskThrowsAnException()

taskClient.CancelListen();
await task;


TaskQueueTestFixture.EnsureSemaphore(semaphoreFile);
Copy link
Owner

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!

var taskInfo3b = GetTestTask(() => TestMethod3(new CancellationTokenSource().Token));

var taskInfo4a = GetTestTask(() => Console.WriteLine("hello"));
var taskInfo4b = GetTestTask(() => Console.WriteLine("hello world"));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 System assemblies do present an uncovered test case here.


namespace Gofer.NET.Utils
{
public class JsonSkipCancellationTokenConverter : JsonConverter
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless you can provide a compelling reason why the cancellation token should be excluded from the serialization, this functionality should be removed altogether.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added cancellation support for enqueued tasks. Json.Net cannot serialize CancellationToken anyway so it was an easiest fix I've could imagine.

@@ -43,7 +44,7 @@ public bool IsEquivalent(TaskInfo otherTaskInfo)
}

for (var i=0; i<Args.Length; ++i) {
if (!Args[i].Equals(otherTaskInfo.Args[i]))
if (!Equals(Args[i], otherTaskInfo.Args[i]))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reason for this change?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be a NulIRefException if Args[i] is null. Actually, I've catched one and did the change to mitigate it.

@brthor
Copy link
Owner

brthor commented Jul 24, 2020

@ig-sinicyn
I did not read your PR description fully before beginning my review. Before I continue further, let's discuss number 2 in your description.

Is the idea that upon canceling the cancellation token passed to TaskClient.Listen that the cancellation should be propagated to the workers?

@ig-sinicyn
Copy link
Author

Is the idea that upon canceling the cancellation token passed to TaskClient.Listen that the cancellation should be propagated to the workers?

Yeah. I've started with adding cancellation to the client's Start()/Listen() methods. But then I've found that there is no way to notify workers about cancellation. So, I've added this feature, too.

Copy link
Author

@ig-sinicyn ig-sinicyn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brthor Thanks for the good review! I've made requested changes, let me know if there are more:)

@ig-sinicyn ig-sinicyn force-pushed the feature/start-cancellation-token branch from 6318fc7 to 12f4e86 Compare July 25, 2020 10:39
@ig-sinicyn
Copy link
Author

up

@brthor
Copy link
Owner

brthor commented Jul 29, 2020

@ig-sinicyn

I let this roll over in my brain for a couple days.

So pretty much my interpretation of what you're doing is allowing users to create tasks that take a cancellation token as an argument and then plugging in a cancellation token tied to the particular TaskClient that begins running that task, so that if that token is cancelled, the task receives the signal and can tidy things up itself.

The intention is good, that the tasks get the cancellation signal and can handle their own stopping.

The implementation, whereby you plug in a value for the token based on an available CancellationToken type argument in the task makes me worried.

Firstly, it requires digging into the args, as I already commented on, and doing special tricks with them on serialization and at invocation time. In general, this is a behavior I'd like to stay away from as much as possible because it can cause unexpected behavior for users who haven't taken the time to familiarize themselves with this special behavior and naively use a task with a CancellationToken argument for whatever reason.

A broader form of the argument is that this special behavior will interfere with regular usage where no intention was presented by the user for a CancellationToken tied to the TaskClient.

So, if the functionality is good, but the implementation has some issues with user-experience, what is the ideal implementation? In my thinking the ideal implementation accommodates the user who wants a CancellationToken tied to the TaskClient but at the same time is only available to the user who specifically requests it.

So I make the following proposal to satisfy those requirements:

  1. Create static method GetTaskCancellationToken on the TaskClient
  2. When this method is called examine the call stack to find the TaskClient instance currently running Listen for the task.

Rough mockup:

--> TaskClient.GetTaskCancellationToken() --> [Task Method] --> TaskInfo.ExecuteTask() --> TaskClient.Listen()
  1. If there is no calling instance of TaskClient in the stack, throw a specific exception, this method can only be used by tasks running inside a TaskClient Listen loop.

  2. Return the CancellationToken associated with the current TaskClient.Listen() loop. Since the TaskClient currently enforces only one listen loop running at a time, this should be as simple as storing the cancellation token in a property when .Listen() is called.

  3. If the currently running Listen loop had no cancellation token passed to it, return the cancellation token created by the TaskClient at the start of Listen, similar to the current implementation.

My preliminary research shows that this implementation is feasible, subject to some testing.

This implementation provides the user a way of obtaining a CancellationToken tied to the TaskClient with a clear gesture that specifies their intention rather than providing a convention that may interfere with regular usage without that intention.

Examining the call stack may seem a little overkill, but this method is likely to be used infrequently only at the start of a task, minimizing performance concerns. Also, assuming it works well when tested, it's likely to be fairly durable.

Please let me know your thoughts.

EDIT: See below comment on using AsyncLocal instead of examining the call stack.

{
TaskQueue = taskQueue;
OnError = onError;
TaskScheduler = new TaskScheduler(TaskQueue);
IsCanceled = false;
}

public async Task Listen()
public Task Listen()
Copy link
Owner

Choose a reason for hiding this comment

The 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:

  1. Change the Start(CancellationToken cancellation) overload to Start(CancellationTokenSource cancellationTokenSource)

  2. Inside of the Listen(CancellationToken cancellation) use CancellationTokenSource.CreateLinkedTokenSource(cancellation) to create a CancellationTokenSource to pass to Start()

  3. Inside the Listen() method overload, do not call the other overload of Listen but instead duplicate the code and call the Start() overload with no parameters.

  4. Inside the Start() overload with no parameters use new CancellationTokenSource() and use it to call the other overload of Start Start(CancellationToken cancellation)

Copy link
Author

Choose a reason for hiding this comment

The 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.

@brthor
Copy link
Owner

brthor commented Jul 29, 2020

Upon a little more research it seems that examining the call stack and getting the actual calling object instance cannot easily be accomplished.

The AsyncLocal construct appears to be a much simpler approach that offers the same benefits.

https://docs.microsoft.com/en-us/dotnet/api/system.threading.asynclocal-1?view=netcore-3.1

In this case a private static field can be used to track the Local Cancellation Token (set during the Start method).

Then the GetTaskCancellationToken simply returns that private field.

@ig-sinicyn
Copy link
Author

@brthor good catch and astonishing level of detail. Thanks for a great response 👍

I do agree with proposed changes, no objections here. Will push fix in a day or two.

…place them with a AsyncLocal<CancellationToken>();
@ig-sinicyn
Copy link
Author

ig-sinicyn commented Aug 20, 2020

@brthor sorry for the nasty delay:( Had a hard month and literally no spare time at all.

I've made changes you've suggested. Code is much more cleaner now and area of changes was reduced a lot. Win-win:)

await Task.Delay(waitTime, CancellationToken.None);
cancellation.Cancel();
await Task.Delay(waitTime, CancellationToken.None);
await task;
Copy link
Owner

Choose a reason for hiding this comment

The 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?

throw new InvalidOperationException("This method must be called from a task client callback");
try
{
await Task.Delay(-1, token);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the real potential for a test hang is here. Perhaps a polling loop with a timeout?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch! Fixed with a timeout parameter.


_listenCancellationContext.Value = token;

var executionTimer = Stopwatch.StartNew();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@brthor
Copy link
Owner

brthor commented Aug 23, 2020

No worries @ig-sinicyn . I think overall it's looking pretty good and the async local change does clean it up a lot!

My only concern left is the potential test hang, I think it's a pretty quick fix though.

I'm going to re-enable the CI on this so the tests will run.

@brthor
Copy link
Owner

brthor commented Aug 23, 2020

Looks like the CI is running but not posting status updates.

Link is here: https://travis-ci.org/github/brthor/Gofer.NET

Tests are passing 👍

…phore

+ Fix TimeSpan and DateTimeOffset parameter passing
@ig-sinicyn
Copy link
Author

If a bug is introduced, and the task fails to exit on cancellation will this introduce a hanging test?

Thanks for spotting, have missed it!

I've fixed the issue with timeout parameter. As it turned out TimeSpan and DateTimeOffset args were not deserialized properly so I've fixed args deserialzation as well:)

@ig-sinicyn
Copy link
Author

up)

1 similar comment
@ig-sinicyn
Copy link
Author

up)

@ig-sinicyn
Copy link
Author

+1 up

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[FeatureRequest] Add overload with cancellation token to the Listen() method
2 participants