Thursday, 25 June 2020

Cancellable Async Function

Standard JavaScript Promises do not provide a mechanism for cancellation. It seems like there have been long discussions about it, but for the moment, nothing has been done. On the other side Bluebird.js provides a very powerful cancellation mechanism.

One major point is to clarify what cancellation means for us. Does it mean that it never resolves or that it gets rejected? Bluebird authors went through this thought process, and while in version 2.0 cancelling was rejecting, in 3.0 it means that it never resolves:

The new cancellation has "don't care" semantics while the old cancellation had abort semantics. Cancelling a promise simply means that its handler callbacks will not be called.

With Bluebird you can cancel a Promise chain, which can be very useful and I won't try to implement myself... I was thinking of a simpler case, when we have an async function performing multiple async calls via await and we would like to ask that method to stop "as soon as possible", which means waiting for the current async call to complete and not performing the remaining ones. .Net makes use of the Cancellation Token concept, and I'll use something similar for this case that I've just described. I'll allow both rejecting and just "abandoning". It mainly comes down to this function:

 

function checkCancelation(cancelationToken){
    if (cancelationToken && cancelationToken.reject){
        console.log("throwing");
        throw new Error("Rejection forced");
    }
    if (cancelationToken && cancelationToken.cancel){
        console.log("cancelling");
        //return a Promise that never resolves
        return new Promise(()=>{});
    }

    return false;
}

//to be used like this:
//let result = await (checkCancelation(cancelationToken) 
//        || getResultAsync());


that we'll use like this:

 

function getLastMessageId(){
 return new Promise(res => {
  setTimeout(() => res(111), 1500);
 });
}

function getMessageText(id){
 return new Promise(res => {
  setTimeout(() => res("this is the last message"), 1500);
 });
}


function formatText(txt){
 let formattedTxt = `[[${txt}]]`; 
 return new Promise(res => {
  setTimeout(() => res(formattedTxt), 1500);
 });
}

async function getLastMessageFormatted(cancelationToken){
    let id = await (checkCancelation(cancelationToken) 
        || getLastMessageId());
    console.log("ID obtained");
    
    let txt = await (checkCancelation(cancelationToken)
        || getMessageText(id));
    console.log("Message obtained");

    let msg = await (checkCancelation(cancelationToken)
        || formatText(txt));
    console.log("Message formatted");

    return msg;
} 

(async () => {
    console.log("-- test 1");
    let msg = await getLastMessageFormatted();
    console.log("message: " + msg);

    
    console.log("-- test 2");
    let cancellationToken = {};
    //reject after 1 second
    setTimeout(() => cancellationToken.reject = true, 1000);
    try{
        msg = await getLastMessageFormatted(cancellationToken);
        console.log("message: " + msg);
    }
    catch (ex){
        console.error("Exception: " + ex.message);
    }

    console.log("-- test 3");
    cancellationToken = {};
    //cancel after 1 second
    setTimeout(() => cancellationToken.cancel = true, 1000);

    //when cancelling we return a simple Promise, that won't keep the program running 
    //(it's real IO-timeout calls, not the Promise itself, what keeps the node.js loop running)
    //If I just want to keep it running longer, just use this keep alive timeout
    setTimeout(() => console.log("keep alive finished"), 10000);
    try{
        msg = await getLastMessageFormatted(cancellationToken);
        console.log("message: " + msg);
    }
    catch (ex){
        console.error("Exception: " + ex.message);
    }
})();

 

I've uploaded the above code to a gist.

Another simple case that I've implemented is having a Promise and preventing its "then handlers" from executing. Notice that I'm just cancelling from the initial promise (not from the one returned by then), while Bluebird's promise chain cancellation is invoked on the last promise returned (and goes all the way up the chain to cancel the active one, which I guess means that in Bluebird Promises are double linked). As you can see I use 2 different strategies for creating a new "cancellable Promise" from the original one.

 

function formatTextAsync(txt){
 let formattedTxt = `[[${txt}]]`; 
 return new Promise(res => {
  setTimeout(() => res(formattedTxt), 1500);
 });
}

function createCancellablePromiseStrategy1(pr){
 let cancelled = false;

 let cancellablePromise = new Promise(res => {
  pr.then(value => {
   if (!cancelled){
    res(value);
   }
   //else we never resolve
   else{
    console.log("promise has been cancelled");
   }
  })
 });
 
 cancellablePromise.cancel = () => cancelled = true;
 return cancellablePromise;
}

function createCancellablePromiseStrategy2(pr){
 let cancelled = false;

 //if the function ran by "then" returns a promise, the promise initially returned by "then" is resolved
 // when that other promise is resolved
 let cancellablePromise = pr.then((value) => {
  return new Promise(res => {
   if (!cancelled){
    res(value);
   }
   //else we never resolve
   else{
    console.log("promise has been cancelled");
   }
  });
 });
 
 cancellablePromise.cancel = () => cancelled = true;
 return cancellablePromise;
}


//let creationStrategy = createCancellablePromiseStrategy1;
let creationStrategy = createCancellablePromiseStrategy2;

let pr1;

const operation = async () =>{
 pr1 = creationStrategy(formatTextAsync("hi"));
 let result = await pr1; //if cancelling pr1 will never resolve, so the next line won't run
 console.log("result: " + result);
};

operation();
pr1.cancel();

 

No comments:

Post a Comment