RFC: improved task linkage semantics
See original GitHub issueThis RFC is not “complete” in that it doesn’t yet offer a concrete proposal, but I wanted to put it out there and start collecting feedback
Summary
In ember-concurrency, when a parent task is cancelled, it cancels any child tasks it is currently yielding. This is desirable behavior we should continue to support, but the issue is that this linkage occurs at yield
-time rather than at perform
-time which causes a variety of subtle issues. The purpose of this RFC is to discuss alternatives that would solve a few ugly corner cases and bring e-c more in line with classic paradigms for process tree management.
Motivation
Here is the canonical example of a parent task performing a child:
parentTask: task(function * () {
yield this.childTask.perform();
}),
The above example results in the parentTask
being “linked” to the child in such a way that if the parent task is cancelled (explicitly or implicitly via .restartable()
), the cancellation will cascade into the linked child task and cancel the child task.
What if there’s a gap between when you perform a task and when you yield
it? Consider the following
parentTask: task(function * () {
// perform `childTask`, which starts running immediately
let taskInstance = this.childTask.perform();
// do some other async before you actually "await" the task instance...
yield timeout(5000);
// "await" the task instance (linkage occurs here)
yield taskInstance;
}),
There is a “gotcha” with the above example: parentTask
doesn’t get linked to childTask
until the yield taskInstance
, so if parentTask
is cancelled (or errors) before yielding the child task instance, the child task will continue to execute. The result of this is an inconsistent cleanup of the task/process tree depending on when you cancel, which leads to very subtle race-conditiony bugs.
The likelihood of encountering these cases increases if you start using (the e-c variants of) Promise.all/race/etc
; often you need to do some setup of multiple child tasks before you’re ready to await all of them, which expands the window of unlinked-ness.
If ember-concurrency made you decide, at perform-time, whether the child task should be linked to the parent task, a lot of these issues would go away. EC actually has an API for this, but people don’t really know about it unless they get console.log()
s alerting them about the dreaded self-cancel loop footgun.
parentTask: task(function * () {
// perform childTask and link it to parentTask
let taskInstance = this.childTask.linked().perform();
// do some other async before you actually "await" the task instance...
yield timeout(5000);
// "await" the task instance
yield taskInstance;
}),
There’s also an API for marking the perform as unlinked()
to prevent linkage when the task instance is yielded:
parentTask: task(function * () {
yield this.childTask.unlinked().perform();
}),
If parentTask
is cancelled in the above example, childTask
will continue to run.
While it’s nice that these escape hatches exist, ember-concurrency’s defaults are non-ideal and can lead to surprisingly behavior. The purpose of this RFC is to propose changing the defaults. Specifically:
- The only thing
yield
ing a task instance should do is await it to run to completion. It should not cause linkage. - By default, performs should be unlinked (or perhaps we get rid of
perform
as a word and always make you chooseperformLinked
orperformUnlinked
). In other words, for something to be linked, you must see reference to the wordlink
in the code.
This would definitely be a breaking change, but I consider this to be one of the main issues that make me hesitant to propose that ember-concurrency be a part of Ember.js Core.
Detailed design
How to access parent task instance?
Any proposed API for making task linkage explicit has to solve the problem of “how do I get a reference to the parent task instance that I am linking to?”
Current .linked()
approach
e.g. this.childTask.linked().perform()
One ugly aspect of this API is that it relies on asking ember-concurrency what the current running task instance is which is essentially a global variable that ember-concurrency maintains as it executes task instances. I’m not sure how big a deal this is, but it’s definitely… weird. I’m not exactly sure how/whether this API would troll people, but here’s an example of improper use:
parentTask: task(function * () {
later(() => {
this.childTask.linked().perform();
// ERROR: "You can only call .linked() from within a task."
}, 500);
}),
This would need to be rewritten as:
parentTask: task(function * () {
let linkedHandle = this.childTask.linked()
later(() => {
linkedHandle.perform();
}, 500);
}),
(Aside: it should be noted that if the parent task instance is cancelled before the timer elapses, linkedHandle.perform()
will essentially no-op: it’ll create/return a task instance that is immediately cancelled.)
Explicitly fetching/passing parent task instance
We could make it so that .linked()
accepts a TaskInstance rather than looking up the global current running task instance. We could also make a .performLinked
method that accepts the parent task instance as the first arg, e.g.:
parentTask: task(function * () {
let currentInstance = /* TODO some API for fetching the running task instance */;
this.childTask.linked(currentInstance).perform();
// or
this.childTask.performLinked(currentInstance);
}),
I don’t have strong opinions as to whether we provided performLinked vs .linked(...).perform()
, but there’s still the issue of how to fetch the current running instance.
Note that encapsulated tasks don’t have this issue since the value of this
within the *perform()
is the task instance:
parentTask: task({
*perform() {
this.owner.childTask.performLinked(this);
}
}),
But for classic tasks, the only feasible API for fetching the parent task instance would be to yield a “special command” to e-c:
import { task, getCurrentTaskInstance } from 'ember-concurrency';
parentTask: task(function * () {
let currentTaskInstance = yield getCurrentTaskInstance;
this.childTask.performLinked(currentTaskInstance);
}),
This approach might surprise people, as generally speaking if you’re using e-c, yield
always means “await the following async thing”. But we can also use yield
to issue special commands to the e-c task runner, and getCurrentTaskInstance
would be a special command that immediately returns a reference to the current running task instance.
CancellationTokens
Regardless of which approach we settle on for accessing the parent task instance to link to, we can think of the argument that you pass to .linked()
as kind of CancellationToken
, which is a common abstraction for managing task/process trees as well as a concept in various proposals to add cancellable promises to JavaScript.
Here is an example of how cancel tokens might look/work with async functions:
async function doStuff(arg1, arg2, cancelToken) {
await uninterruptibleWork();
cancelToken.cancelIfRequested();
await interruptibleWork(cancelToken);
await uninterruptibleWork();
cancelToken.cancelIfRequested();
}
Basically, the caller is responsible for constructing/providing a cancelToken to doStuff
. The caller can request cancellation at any time on the cancelToken
, but the async function ultimately decides when it’s a good time to cancel via called .cancelIfRequested()
at discrete times. Whereas ember-concurrency tasks are assumed to be cancellable at any point, async functions are uncancellable by default, and you must “sprinkle in” cancellability where you need it using cancel tokens.
The story for “linkage” in ember-concurrency should probably factor in CancellationTokens. Perhaps it makes sense that .linked()
/ .performLinked()
should also work with cancel tokens, such that if you do .linked(cancelToken).perform()
, you have the same guarantees as if you’d linked to a parent task: the moment cancellation is requested on the token, the ember-concurrency task will immediately cancel.
Additionally, it should be possible to convert a task instance into a CancellationToken for interop with async functions, e.g.:
import { task, getCurrentTaskInstance } from 'ember-concurrency';
parentTask: task(function * () {
let currentTaskInstance = yield getCurrentTaskInstance;
this.doStuff('a', 'b', currentTaskInstance.asCancelToken());
}),
async doStuff(arg1, arg2, cancelToken) {
// ...
}
Error handling?
How should we handle the following case:
parentTask: task(function * () {
let me = yield getCurrentTaskInstance;
// perform linked tasks but don't await them
let a = this.childTask.performLinked(me);
let b = this.childTask.performLinked(me);
let c = this.childTask.performLinked(me);
yield timeout(10000)
}),
What happens if one of the child task instances errors? Should parentTask
immediately cancel? Should it only cancel if it is yield
ing a childTask?
I’d made a decision long ago that if you yield a child task that gets cancelled, the cancel bubbles up in a way that is un-catch
-able (internally this is achieved by return
ing from the generator function rather than throw
ing). I made the change because at the time if you wanted to catch errors thrown from perform tasks, you had to check whether the error was a “TaskCancellation”, e.g.
parentTask: task(function * () {
try {
yield this.childTask.perform();
} catch(e) {
if (isTaskCancellation(e)) {
return; // disregard
} else {
// an actual error occurred; do stuff.
}
}
}),
With the change, you don’t have to write the isTaskCancellation
guards all over the place, but you also lose any way to treat an expected child task cancellation as an error. We should revisit this decision as we consider how to handle performLinked-but-not-awaited getting unexpectedly cancelled.
Perhaps we can draw some inspiration from Erlang:
The default behaviour when a process receives an exit signal with an exit reason other than normal, is to terminate and in turn emit exit signals with the same exit reason to its linked processes. An exit signal with reason normal is ignored.
A process can be set to trap exit signals by calling: process_flag(trap_exit, true)
When a process is trapping exits, it does not terminate when an exit signal is received. Instead, the signal is transformed into a message {‘EXIT’,FromPid,Reason}, which is put into the mailbox of the process, just like a regular message.
An exception to the above is if the exit reason is kill, that is if exit(Pid,kill) has been called. This unconditionally terminates the process, regardless of if it is trapping exit signals.
This makes sense for Erlang, but ember-concurrency task instances don’t (currently) have any concept of mailbox / event queue or a receive loop, so this wouldn’t really fall neatly into ember-concurrency’s model. Perhaps we need some additional utilities to catch/report on the status of child tasks, but I’m not sure what?
How we teach this
TODO
Drawbacks
TODO
Alternatives
TODO
Unresolved questions
TODO
Issue Analytics
- State:
- Created 5 years ago
- Comments:15 (11 by maintainers)
Top GitHub Comments
@martletandco It’s off-topic enough to save discussion for another RFC (or the Discord channel) but here are some sketches of e-c process APIs
I’m not sure what the use cases are for linkage after perform but we can always add it in later if we need it. I guess we can start to deprecate unqualified
.perform()
s over time in favor of.performLinked()
or.performUnlinked()
.@happycollision Your explanation makes sense, but makes me think we should consider improved/simpler mental models to avoid requiring that you think through every possible cancellation corner case. In other words, it’s unfortunate that seasoned e-c users must have a fear of
yield
as a possible cancellation point, as it clutters thinking, hampers refactoring, and leads to difficult-to-test regressions whenever you make modifications to a task that at one point was delicately designed to avoid certain sequences ofyield
s that might feasibly lead to an invalid state.I don’t know what that better mental model is but if we’re thinking in terms of 1.0.0 release goals, I’d like to address any common cases where e-c requires developers to have eternal vigilance to avoid race conditions.
It’s pretty unlikely that you would encounter the situation you described in actual production code, but I believe something like this would reproduce it:
I believe this would result in all
restartableChildTask
instances, except for the last, to fail todoImportantWork
; internally, thegetCurrentTaskInstance
would immediately produce a value, but e-c wouldn’t continue executing until theactions
run loop queue, which would happen after the otherthis.restartableChildTask.perform()
s.This is a rare and contrived situation that probably points to something else that “off” about your code (e.g. calling restartable tasks multiple times in a row and depending of the e-c behavior of synchronously executing the first chunk of a task). But aside from this very unusual corner case, I believe there’s a more common need for some ability to mark regions of a task as uncancellable/uninterruptible, which would solve this use case AND other use cases of actual async (and not the short-term run loop “async” that completely resolves within one javascript event loop tick). Maybe we add a task modifier called
.uninterruptible()
which automatically prepends (pre-curries) the task instance to the arguments so that it can be used to signal when the task instance becomes interruptible again.It might be a weird unexpected side effect that
.uninterruptible()
would start providing the task instance as the first arg, but maybe not? I dunno.That all said…
For the purposes of this RFC, I’m still thinking it’s overkill that we require passing the parent task instance to
childTask.linked().perform()
when we can automatically derive it with 99.999% confidence. The only time it seems useful would be for async function interop.