-
Notifications
You must be signed in to change notification settings - Fork 4
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
Asynchronous Promise Cancellation with Cancellable Promises, AbortController and Generic Timer #297
Comments
Another place where this can be applied is the But the underlying So having a consistent way of aborting these sorts of operations would be useful. Being able to abort when we cancel |
Note that we have a usecase in This can be quite useful for blocking async generator methods, so they can cancel their async generator context when the stop signal is raised. |
It could also be 2 separate libraries, one for in-memory ("in/intra-process") locking, another for IPC locking. |
|
It would worth considering getting this solved #307 before this issue so we can actually prove that we are properly cancelling promises. |
Based on this comment srmagura/real-cancellable-promise#1 it seems that this package is the one we should be using. |
GRPC calls have Web requests Combining it with that cancellable promise wrapper, we may finally have the ability to properly cancel our async operations which can impact our Discovery system, Node Manager queue, async locks... pretty much all promises that rely on underlying side-effects/IO and not just computational promises. Almost all uses of promises are IO/side-effectful promises though! |
I'm having a look at To create a new let cancelledFlag = false;
const cancel = () => {
// This needs to cause prom to throw an `Cancellation()` error.
cancelledFlag = true;
}
const prom = () => {
// Doing things
while(true) {
// We can break out of the loop with an error
if (cancelledFlag) throw new Cancellation();
// Do our things
}
}
const cancellablePromise = new CancellablePromise(prom(), cancel); But we need a way to chain cancellation by having our const cancellable: Array<() => void> = []
cancellable.push(() => thing1.cancel());
cancellable.push(() => thing2.cancel());
cancellable.push(() => thing3.cancel());
cancellable.push(() => thing4.cancel());
cancellable.push(() => thing5.cancel());
const cancel = () => cancellable.forEach( thing => thing()); so overall our cancellable methods would look something like this; const cancellable: Array<() => void> = [];
let cancelledFlag = false;
const cancel = () => {
// This needs to cause prom to throw an `Cancellation()` error.
cancelledFlag = true;
cancellable.forEach( cancel => cancel())
}
const prom = () => {
// Doing things
while(true) {
// We can break out of the loop with an error
if (cancelledFlag) throw new Cancellation();
// Do our things
const cancellablePromise1 = thing1();
cancellable.push(() => cancellablePromise1.cancel());
await cancellablePromise1;
const cancellablePromise2 = thing2();
cancellable.push(() => cancellablePromise2.cancel());
await cancellablePromise2;
}
}
const cancellablePromise = new CancellablePromise(prom(), cancel); |
Just remember that to truely cancel the promise, you must also cancel the underlying side-effect. The way to do it depends on what the final side-effectful op is. In most cases, they support an For example:
But just because you stop the generator, doesn't mean you stop the underlying side-effect. We will need to propagate such calls to the underlying side-effect by calling |
My thinking is that we should build in |
Timer inheritance is technically a separate problem from having abortable asynchronous contexts. It's just often used together for our deadlines... etc. In some cases I've supported passing a |
making things cancel-able seemts to be a pretty major change that will see us converting quite a few methods such as It could look something like; @cancelable()
public async thing(arg1, arg2, cancelEventEmitter) {
await otherThing(arg3, arg4, cancelEventEmitter);
// Or
const prom = otherThing(arg3, arg4);
cancelEventEmitter.on('cancel', prom.cancel());
await prom;
} It's easy enough to wrap a promise with |
I'd prefer that cancellable promises are specifically implemented in only areas where we need it. Not whole-sale conversion.
It would make sense that by using Would it be done through an additional parameter like That's how alot of existing async methods do the job like In other cases like async generators, you would need integration of the |
We don't have to convert everything wholesale. But connection related procedures such as findNode, refreshBucket and possibly pingNode should be cancel-able. I suppose we don't have to go as deep as cancelling any active GRPC calls or connections currently being created. Just the worst offenders that are creating connections / preforming discovery using a loop. As for how it's implemented. We would have to pass a cancel event emitter, Looks like using the |
NixOS/nixpkgs#146440 this is the PR waiting for to upgrade our nodejs. |
The If we want to be browser-compatible, we should prefer to use So in order to do this, our cancellable asynchronous functions has to take an
The standardised interface is that the last parameter is an "options" object that has a
Functions that can be aborted needs to listen on the abort event, that triggers a boolean switch in the computational context that causes it to throw an aborting exception. It's the exception that bubbles up and cancels/stops all computation within an abort controller context. The abort handler itself cannot be throwing an exception, as that exception is in its own context, and not in the call stack of the function(s) being aborted. To integrate with the cancellable promise, one has to create a higher order function like this:
The The To be flexible it also needs to do 3 things:
The result is a cancellable promise which is somewhat easier to use than having to manage a bunch of It's important to note that aborting is asynchronous, calling This is similar to our events system with
|
One way to implement async cancelAsync(): Promise<void> {
this.cancel();
try {
await this;
} catch (e) {
if (e instanceof AbortionError) return;
// do we want to do this? What does `emitAsync` do?
throw e;
}
// If already finished, and no exception, then just return
// Or throw an error indicating that it is already finished (can't cancel what is already finished)
return;
} |
As for the wrap function: type F = (...params: [...Array<unknown>, { signal: number }] | [...Array<unknown>]) => unknown;
function wrap<T extends F>(f: T): T {
throw new Error();
}
function f1(a: number, options: { signal?: number, x: number } = { x: 4 }): number {
return options.signal ?? 123;
}
function f2(a: number, options: { signal?: number, x: number }): number {
return options.signal ?? 123;
}
function f3(a: number, options: { signal: number }): number {
return options.signal;
}
function f4(a: number, options: { signal: number } = { signal: 4 }): number {
return options.signal;
}
const g1 = wrap(f1);
const g2 = wrap(f2);
const g3 = wrap(f3);
const g4 = wrap(f4); Remember this means the resulting function can still pass |
I'm testing explicit signal cancellation during the |
This would only apply to situations where the signal is passed in from the outside. The signal may already be aborted, and therefore the timer should not be constructed, or even just cancelled straight away. We can make use of a special symbol as the reason for explicit cancellation and ignore such reasons when given. |
Currently We should make This also makes it closer to how we expect Promise rejection can still happen but only to things chained after the timer promise (which then has to handle a rejection). Making this would simplify the |
Actually to be precise, this also causes an unhandled promise rejection:
It behaves the same as a regular
Both are unhandled rejections. I think this is fine for PC. But for |
This is now done by doing:
Note that this means when cancelling the timer, all resolutions and rejections are ignored. We don't distinguish explicit cancellation from other kinds of errors that may occur. This is may be a controversial decision, but I tried to distinguish explicit cancellation, but it doesn't work because the catch handler will always get actual rejection which is supposed to be the user-passed reason. During multiple cancellation, other reasons may be supplied, and we don't want to make those fail just because the reasons are different. Therefore Finally this doesn't apply to I guess this problem comes down to whether |
Ok so with the new exception class creation, we get something like:
As you can see, this stack trace is pretty useless, doesn't even tell us which decorated function actually throw the error. So we're going to need to redo the stack trace anyway. |
Ok we have a problem with resetting the stack trace. The issue is that the when the error occurs due to a timeout, it is occuring inside a process timer stack. At that point it's not possible to reset the stack trace according to the decorated function. This is because the stack information no longer exists while in scope of the timers. The alternative is to construct the exception before hand, and then throw it inside the timer. This ensures that the stack trace now looks like:
This gives us more useful information as it is now telling us where exactly this exception was triggered, and specifically at:
However this is exposing a bit too much useless information like:
Comparing this to the async-init
I think the main issue is that doing the So the only good solution is the middle solution, instantiate the exception as part of the decorator, but not as part of the the timer. And if I find a good way to reset the stack trace but only when throwing it.... |
Actually it appears |
So basically with:
And putting that inside each decorated
And this matches how the |
Actually I'm going to remove the
|
Note that |
The Initial syntax tests are working. One thing though, if the target function returns a |
For now the |
The HOF variants will work like this: // import cancellable from '@/contexts/decorators/cancellable'; // <- this is for decorators
import cancellable from '@/contexts/functions/cancellable';
const f = async (a, b, c, ctx): Promise => { };
// Without the `@context` param decorator, we need to have the `ctx` as the first parameter
const g = cancellable(
(ctx, ...params) => f(...params, ctx)
);
// Maybe a type like this:
// <T extends Array<any>, V> (
// (...params: [ContextCancellable, ...T]) => PromiseLike<V>) =>
// (...params: [...T, ContextCancellable]) => PromiseCancellable<V>
g(a, b, c, ctx); It will need to preserve the internal types of Note that this would be complicated if the target function has overloaded types. It wouldn't really preserve it. |
This answer https://stackoverflow.com/a/52760599/582917 suggests providing the most general overload if this occurs. Seems like a good practice in case overloaded signatures exist. The implementation signature is ignored remember. |
Ok I was right, the types actually work for if I put So types-wise it does work, the inference makes sense. But once you get the Then |
So this is how you use it:
By having |
Timed and Cancellable HOF variants are done and tested. Moving to combinations of the 2 |
As per the comment #438 (comment), the |
The generic types of To ensure a standardised usage of It didn't actually apply to the May also get |
The CI/CD is setup for that https://gitlab.com/MatrixAI/open-source/js-timer |
It can be introduced to Polykey and eliminate the |
Ok so I have a bit of a confusion regarding The problem is that both of these decorators will use
So there are 2 relationships here: A. If timer times out, signal is aborted However we have 4 situations:
This is already being done in the |
So one thing, is that regardless of the 4 situations, A. relationship should old. If the timer times out, the signal to the target wrapped function should always be aborted. The abortion signal reason should Note that this does not mean that the promise is rejected. Whether the promise rejects or not depends on the actual function itself. Of course this is where the So in situation 1, relationship A will be setup by the decorator. In situation 2 and 3, the relationship A will also be setup by the decorator. In 2, the new timer will abort a chained signal. In 3, a timer fulfilment will lead to signal abortion. In situation 4, where both timer and signal are inherited. This is where things get complicated. Do we assume that the timer and signal have the relationship already setup? Because that assumption only holds if we expect the context object was passed in from an outer decorator. But if the user just created a We could assume that if you did that, you did it intentionally for some reason, and the decorator won't do any magic. Alternatively the decorator can check if there is a relationship, however it's not really able to do this since the signal does not tell you if there are event handlers pointing to the timer. I think we have to assume that such an explicit passing means that you have already explicitly setup the relationship A. And if you did not, then it's a runtime error, and we cannot do anything here. Furthermore... in situation 1, 2, 3, 4 a new downstream signal must be created due to The next problem is relationship B. In some cases, I can see that if the signal aborts, this results in the timer being cancelled (not timed out!!). This is because if the signal is already aborted, there's no need to keep the timer running, since the timer will eventually just timeout and abort the signal. Relationship B should hold in: situation 1, situation 2. But in situation 3, and 4, the timer is inherited, and now the problem is that with Relationship B isn't actually that important. Once the signal is aborted, even if the timer times out and tries aborting the signal, its operation will be a noop. The reason won't change either. A signal can only be aborted once, subsequent aborts are ignored. And we know that the timer will eventually be cancelled once the async operation completes. We can say that relationship B only holds if the decorator constructed the timer. If it he decorator did not construct the timer, then the signal does not try to cancel the timer. When the decorator constructs the timer, the timer is not exposed to the outside, and this is a safe operation. Finally there's the issue of the function completion, when the function completes, it's necessary to cancel the timer to prevent it from continue to running. But we don't do this, if the timer was inherited. This is only done if the timer was constructed by the decorator itself and thus the decorator "owns" the lifecycle of that timer. This would only occur in situation 1 and 2. So let's call this relationship C: if a timer is constructed by the decorator, then it must be cancelled at the end by the decorator Consequently therefore:
Separately whether the async op does an early rejection or not depends on the laziness. |
Currently relationship B is applied in situation 4. This is where things get a bit complicated. I said that it should be expected that the caller has already setup the relationship. Where if the signal aborts, then therefore the timer should be cancelled. But if that's the case, why is relationship A also not enforced? It's because it would require us to chain the signal to enforce that, and chaining the signal is what we do in situation 2. Now is it actually a problem to readd these relationships? Yes it can be... Consider if we created a nested series of async functions, each propagating the context down. That means everything except the outermost function is in situation 4. And if the relationships are added over and over, this will lead to multiple calls to signal abortion during a timeout, and also multiple calls to timer cancellation during signal abortion. Every function call will result in an additional 2 handlers being added. This problem may be solved if we were able to know if the relationship exists or not instead of making assumptions. Unfortunately the signal event target does not give us a way to interrogate the event listeners. And the timeout while giving us a handler, doesn't really tell us what the handler is doing without doing meta work on the handler code. Therefore relationship B should not be applied to situation 4. And the tests that rely on this should expect to fail because they are calling with a incorrectly setup context object. |
Async cancellation should be applied across the board:
For locking MatrixAI/js-async-locks#21 illustrates the problem is more complex. Further R&D required to deal with cancellable async generators or generators... let's see how we go first. |
Issue description is still useful to indicate where changes still need to be applied. |
Specification
There are a number of places where we have asynchronous operations where there is a timeout applied. When timedout, certain cancellation operations must be done. Sometimes this means cancelling a promise which may involve IO, and other cases it means breaking a
while (true)
loop.These places are:
src/utils.ts
- thepoll
utility function is being used by a number of tests to testing if an asynchronous function returns what we want using thecondition
lambda, this is inherently loopy due to its naming ofpoll
, this means cancellation occurs by breaking the loop, and nosetTimeout
is being used here, when the loop is boken, an exception is thrown asErrorUtilsPollTimeout
src/network/ForwardProxy.ts
andsrc/network/ReverseProxy.ts
- these make use of theTimer
object type, that is used to allow timer inheritance. Unlike poll, here we are usingPromise.race
to cancel awaiting for a promise that will never resolve. In this situation, the problem is that we want to cancel waiting for a promise, the promise operation itself does not need to be cancelled, seePromise.race
usage insrc/network/ConnectionForward.ts
andsrc/network/ConnectionReverse.ts
src/grpc/GRCPServer.ts
same situation as the proxies, here it's a promise we want to stop waiting for, the promise operation itself does not need to be cancelled.src/identities/providers/github/GitHubProvider.ts
- this is the oldest code making use of polling, as such it has not been using thepoll
ortimerStart
andtimerStop
utilities, it should be brought into the foldsrc/status/Status.ts
- this uses polling inwaitFor
src/discovery/Discovery.ts
- we need to be able to abort the current task (discovery of a node/identity) rather than awaiting for it to finish when we stop the discovery domain CLI and Client & Agent Service test splitting #311 (comment)Note that "timer" inheritance needs to be considered here.
So there are properties we need to consider when it comes to universal asynchronous "cancellation" due to timeout:
while (true)
loopundefined
version of the timeout, perhaps like an infinite version, this is so that function signatures can taketimer
object, which can defaulted tonew Timer(Infinity)
My initial idea for this would be flesh out the
Timer
type that we are using, include methods that allow one to start and stop.For 2.3, we actually have to integrate
AbortController
. This will be important for cancelling async IO operations. We can then integrate this into our timeout system itself.Imagine:
In a way it can be an abstraction on top of the
AbortController
.General usage summary of the abort controller.
More prototyping has to be done here.
For while loops, we can check if the signal is true, and if so cancel the loop.
There's 3 portions to this issue:
cancellable
wrapper higher order function/decoratorcancellable
wrapper and optionally the timer/timeout mechanismTimer/Timeout problem
Many third party libraries and core core has a
timeout
convenience parameter that allows an asynchronous function to automatically cancel once a timeout has finished.The core problem is that
timeout
is not easily "composable".This is why the
Timer
abstraction was created.This allowed functions to propagate the
timer
object down to low level functions while also reacting to to this timer. This means each function can figure out if their operations have exceeded the timer. But this is of course advisory, there's no way to force timeout/cancel a particular asynchronous function execution unlike OS schedulers that can force SIGTERM/SIGKILL processes/threads.This is fine, but the major problem here is how to interact with functions that only support a
timeout
parameter. This could be solved with an additional method to theTimer
object that gives you the remaining time at the point the method is called.For example:
It is up the function internally to deal with the timer running out and throwing whatever function is appropriate at that point. Remember it's purely advisory.
All we have to do here is to create a new
class Timer
that supports relevant methods:This would replace the
timerStart
andtimerStop
functions that we currently have in oursrc/utils
.Functions that take a
Timer
should also support anumber
as well as convenience, and this can be made more convenient just be enabling thetime?: number
parameter above. Note that by default if there's no time set for theTimer
, we can either default in 2 ways:Certain functions may want to default to infinity, certain functions may wish to default to
0
. In most cases defaulting toInfinity
is probably the correct behaviour. However we would need to decide whatgetTimeout()
means then, probably return the largest possible number in JS.Because this is fairly general, if we want to apply this to our other libraries like
js-async-locks
, then this would need to be a separate package likejs-timer
that can be imported.Cancellable Wrapper HOF and/or Decorator
The timer idea is just a specific notion of a general concept of asynchronous cancellation. In fact it should be possible to impement timers on top of general asynchronous cancellation.
There may be a need to cancel things based on business logic, and not just due to the expiry of some time.
In these cases, we want to make use of 2 things:
AbortController
andCancellablePromise
.The
AbortController
gives us anEventTarget
that we can use to signal to asynchronous operations that we want them to cancel. Just like the timer, it's also advisory.The
CancellablePromise
gives us a way to cancel promises without having to keep around a separateAbortController
object. The same concept can apply toAsyncGenerator
as well, so one may also haveCancellableAsyncGenerator
. Note thatAsyncGenerator
already has things likereturn
andthrow
methods that stop async generator iteration, however they are not capable of terminating any asynchronous side-effects, so they are not usable in this situation.The idea is to create a
cancellable
higher order function wrapper that works similar topromisify
andcallbackify
.The expectation is that a lower order function takes in
options?: { signal?: AbortSignal }
as the last parameter, andcancellable
will automate the usage the signal, and return a new function that returnsCancellablePromise
.This higher order
cancellable
has to function has to:promisify
how to do this (note that overloaded functions will need to be using conditional types to be supported)AbortSignal
and connect it to thecancel*
methods of the returned promise.CancellablePromise
must be infectious, any chained operations after that promise must still be aCancellablePromise
. BasicallypC.then(...).then(...)
must still return aCancellablePromise
, even if then chained function returns a normalPromise
.CancellablePromise
has 2 additional methods:cancel
andcancelAsync
, the difference between the 2 is equivalent to ourEventBus.emit
andEventBus.emitAsync
. You would generally useawait pC.cancelAsync()
.cancelAsync
orcancel
methods of the cancellable promise. If no exception is thrown, then the method is successful because it is assumed that the promise is already settled. If a different exeption is thrown, then it is considered an error and will be rethrown up.@cancellable
wrapper similar to our@ready
decorator used injs-async-init
.AsyncGenerator
as well, in which case the@cancellable
would return aCancellableAsyncGenerator
instead.promisify
andcallbackify
.An example of this being used:
Or with a decorator:
You should see that it would be possible to implement the timer using this concept.
The timer parameter would just be a convenience parameter to automate a common usecase.
In some of our functions, I can imagine that end up supporting both timer and signal:
If a cancellation error is thrown without actually be cancelled, then this is just a normal exception.
Refactoring Everywhere
Lots of places to refactor. To spec out here TBD.
Additional context
@matrixai/js-file-locks
to introduce RWlocks in IPC #290 - Locking operations can make use of thispoll
usage and the difference between timersTasks
Timer
class into oursrc/utils
AbortController
intoTimer
Timer
integration intopoll
utilityTimer
integration in network proxiesTimer
integration into identitiesTimer
into our tests and ensure that it works with jest mock timersThe text was updated successfully, but these errors were encountered: