In JavaScript, how to stop execution of a Promise which lost a Promise.race?

414 Views Asked by At

I did not find a straightforward way to stop execution of a Promise when using AbortController. Let me explain.

To await async function _aget_Cards within a time limit defined by timeout parameter, I created two Promises and raced them as per the code block below.

async function xxx (userCN :string, timeout :number) :Promise<CardDetailsWithCCBal[]|unknown> {
    const controller = new AbortController()
    const raced_promise =  Promise.race ([
        new Promise ((resolve, reject) => {
            _aget_Cards (resolve, reject, userCN, controller)
        }),
        new Promise ( (resolve, reject) => {
            setTimeout ( () => reject('CONTROLLER_TIMEOUT'), timeout)
        })
    ])
    raced_promise.catch((error) => {
        console.log ('Error on raced promise detected')
        if (error === 'CONTROLLER_TIMEOUT') {
            console.log ('Detected error is a CONTROLLER_TIMEOUT ... Sending "abort" signal to controller for _aget_Cards')
            controller.abort()
        }
    })
    return raced_promise
}

You will note that I raised a specific 'CONTROLLER_TIMEOUT' error with reject('CONTROLLER_TIMEOUT') if the time limit Promise completes earlier. In the handling of that specific error, I invoke an abort() on the AbortController with controller.abort().

The callbacks resolve, reject and the AbortController instance controller are passed as parameters in the first Promise _aget_Cards (resolve, reject, userCN, controller).

In the controller.signal.addEventListener of the _aget_Cards function, I set doAbort = true, as per code block below.

async function _aget_Cards (resolve :(value :unknown) => void, reject :(reason ?:any) => void, userCN :string, controller :AbortController)  { 
    
    let doAbort = false
    controller.signal.addEventListener ("abort", () => {
        console.log ('Controller received "abort" signal ... aborting ...')
        doAbort = true
    })  

    let start_time = Date.now()
    if (doAbort) return
    const cardDetails_lists :CardDetails[][] = await Promise.all ( [
        aget_CardDetails_List (userCN, true),  // Get Primary cards
        aget_CardDetails_List (userCN, false)  // Get Secondary cards
    ])
    console.log(`List of Primary and Secondary Cards obtained in ${(Date.now()-start_time)/1000}s`)
    
    start_time = Date.now()
    if (doAbort) return
    const cardDetails_list :CardDetails[] = [...cardDetails_lists[0], ...cardDetails_lists[1]]
    cardDetails_list.sort ( (a, b) => {
        let a_sortVal :string = `${a.cardType}${a.cardNo}`
        let b_sortVal :string = `${b.cardType}${b.cardNo}`
        return (a_sortVal < b_sortVal) ? -1 : (a_sortVal > b_sortVal) ? 1 : 0 
    })

    if (doAbort) return
...

You will note that I have been using if (doAbort) return in more than one place to abort execution. I did not find a straightforward way to force the return when an abort was detected.

Rejecting the promise with reject inside controller.signal.addEventListener does not affect the execution of _aget_Cards. Neither does raising an error.

Your help would be much appreciated.

2

There are 2 best solutions below

0
Bergi On

I have been using if (doAbort) return in more than one place to abort execution. I did not find a straightforward way to force the return when an abort was detected.

Yes, it's not as straighforward as registering the abort signal with async function execution to abort execution (typically during await) when the signal is raised. JavaScript has no such feature :-/

Testing manually means that you stay in control of the places where control flow may be aborted. In your case, you are e.g. testing before and after the sorting, so that the sorting itself cannot be "interrupted" - it executes either all or nothing.

However, you can simplify your code quite a bit:

  • do not use Promise.race with a promise that rejects after a certain time. Just abort the controller after a certain time
  • instead of constructing the AbortController manually and doing setTimeout and controller.abort() yourself, use the AbortSignal.timeout() helper function
  • avoid passing an async function as the executor to new Promise! You already get the promise by just calling the function, and you won't have to deal with calling resolve()/reject() at the right time
  • pass only the AbortSignal to the function that should be stopped, not the whole AbortController. You pass the controller to functions that should be able to stop others.
  • do not use a boolean variable doAbort that you set yourself in an event listener on the abort signal. Just refer to signal.aborted
  • do not use if (signal.aborted) return, rather call signal.throwIfAborted() that will throw the abort reason and have it propagate up the call stack as an exception

So your code becomes

async function xxx(userCN: string, timeout: number): Promise<CardDetailsWithCCBal[]|unknown> {
    const signal = AbortSignal.timeout(timeout)
    return _aget_Cards(userCN, signal).catch(error => {
        console.log ('Error on promise detected')
        if (error === signal.reason) {
            console.log ('Detected error is a CONTROLLER_TIMEOUT...')
        }
        throw e
    })
}

async function _aget_Cards (userCN: string, signal: AbortSignal) {
    let start_time = Date.now()
    signal.throwIfAborted()
    const cardDetails_lists: CardDetails[][] = await Promise.all([
        aget_CardDetails_List(userCN, true),  // Get Primary cards
        aget_CardDetails_List(userCN, false)  // Get Secondary cards
    ])
    console.log(`List of Primary and Secondary Cards obtained in ${(Date.now()-start_time)/1000}s`)
    
    start_time = Date.now()
    signal.throwIfAborted()
    const cardDetails_list: cardDetails_lists.flat().sort((a, b) => {
        let a_sortVal: string = `${a.cardType}${a.cardNo}`
        let b_sortVal: string = `${b.cardType}${b.cardNo}`
        return (a_sortVal < b_sortVal) ? -1 : (a_sortVal > b_sortVal) ? 1 : 0 
    })
    signal.throwIfAborted()
    …
}

Finally, you don't need to call signal.throwIfAborted() that often. The signal would not fire while your synchronous code executes, so if it was not already aborted before the sorting it won't be aborted immediately after the sorting either. You normally only need this method to check after an await whether the signal has been triggered in the meantime. Even better though would be make the promise that you're awaiting reject early when the signal fires, by passing the signal to the function you're calling and handling it inside there:

async function _aget_Cards (userCN: string, signal: AbortSignal) {
    let start_time = Date.now()
    const cardDetails_lists: CardDetails[][] = await Promise.all([
        aget_CardDetails_List(userCN, true, signal),  // Get Primary cards
//                                          ^^^^^^
        aget_CardDetails_List(userCN, false, signal)  // Get Secondary cards
//                                           ^^^^^^
    ])
    console.log(`List of Primary and Secondary Cards obtained in ${(Date.now()-start_time)/1000}s`)
    
    start_time = Date.now()
    const cardDetails_list: cardDetails_lists.flat().sort((a, b) => {
        let a_sortVal: string = `${a.cardType}${a.cardNo}`
        let b_sortVal: string = `${b.cardType}${b.cardNo}`
        return (a_sortVal < b_sortVal) ? -1 : (a_sortVal > b_sortVal) ? 1 : 0 
    })
    …
}
2
Youri Malleck-Ahmed On

Thank you Bergi for bringing to my attention the anti-pattern for Promise constructor, and for your other comments to simplify the code, like eliminating the Promise.race to handle the timeout.

I reviewed my code and simplified it so that I would not even have to use AbortController.

The entry-point async function aget_Cards is called by the client as per code below.

// Call made by Client
let start_time = Date.now()
aget_Cards('9999',8000)
    .then ( (cardDetailsWithCCBal_list :CardDetailsWithCCBal[]|unknown) => {
        console.log (cardDetailsWithCCBal_list)
        console.log(`\n\nAll finished in ${(Date.now()-start_time)/1000}s`)
    })
    .catch ( (error :Error) => {
        console.log (`Error is : ${error}`)
    })

async function aget_Cards is defined as per below. I create a Promise in this function and pass the callbacks resolve and reject to a lower level function _aget_Cards.

async function aget_Cards (userCN :string, timeout :number) :Promise<CardDetailsWithCCBal[]|unknown> {
    return new Promise ((resolve, reject) => {
        _aget_Cards (resolve, reject, userCN, timeout)
    })
}

In the lower level function _aget_Cards, I use setTimeout which invokes the reject callback function passed as parameter, and sets a stop boolean variable. I had to pass the reject callback function as parameter to this function because calling Promise.reject() in the setTimeout would not have worked. Also, I catch any error and reject with the error.

async function _aget_Cards (resolve :(value :unknown) => void, reject :(reason ?:any) => void, userCN :string, timeout :number)  { 
    
    let start_time = Date.now()
    let stop = false
    
    setTimeout ( () => {
            reject('CONTROLLER_TIMEOUT')
            stop = true
        }
        , timeout
    )

    try {
        if (stop) return
        const cardDetails_lists :CardDetails[][] = await Promise.all ( [
            aget_CardDetails_List (userCN, true),  // Get Primary cards
            aget_CardDetails_List (userCN, false)  // Get Secondary cards
        ])
        console.log(`List of Primary and Secondary Cards obtained in ${(Date.now()-start_time)/1000}s`)
        if (stop) return

        start_time = Date.now()
        const cardDetails_list :CardDetails[] = [...cardDetails_lists[0], ...cardDetails_lists[1]]
        cardDetails_list.sort ( (a, b) => {
            let a_sortVal :string = `${a.cardType}${a.cardNo}`
            let b_sortVal :string = `${b.cardType}${b.cardNo}`
            return (a_sortVal < b_sortVal) ? -1 : (a_sortVal > b_sortVal) ? 1 : 0 
        })
        console.log(`List of Primary and Secondary Cards have been sorted`)
        if (stop) return
        // ...
        console.log(`Fulfilling promise _aget_Cards ...`)
        resolve(cardDetailsWithCCBal_list)
    } catch (error) {
        reject (error)
    }
}

I tested the above and it works.