How to "cancel/abort" the side effect of an already fired asynchronous task?

Viewed 160
let state = 'A'

async function runTask() {
  state = await someApi()
}

// `someApi`'s execution time may vary, as well as its response.
  1. Initial state is A, and then runTask get called.
  2. For some reason, runTask is called again before the previous call get settled.
  3. Second call to someApi resolves with response C, and the state is updated accordingly.
  4. First call to someApi resolves with response B, and the state is updated accordingly, which is undesirable.
time  state

 |      A
 |      A  --- runTask
 |      A     someApi(call #1)
 |      A         |
 |      A         |
 |      A  ------------------------ runTask (get called before previous one settles)
 |      A         |              someApi(call #2)
 |      A         |                    ‖
 |      A         |                    ‖
 |      A         |                    v
 |      C  --------------------- response #2: C
 |      C         |
 |      C         |
 |      C         v
 |      B  -- response #1: B
 |      B
 |      B
 v

How should I solve this problem gracefully in javascript? A workaround I can think of is: to assign timestamp on each request and maintain a global lastUpdated variable to prevent any obsolete & late responses from modifying the state. However, it feels dirty and does not scale well since there are usually lots of states and asynchronous actions.


Update

Bounty rewarded on the earliest favorable answer.

4 Answers

Here, I maintain an array with all the XMLHttpRequests. In the case of a new request the old requests will be aborted and removed from the array. When a xhr is loaded it will be removed from the requestArr and the xhr result will be returned.

In the case of different xhrs where not all are relevant in someApi() I create the custom property requestType on the xhr. Alternatively everything in the array could be aborted and removed.

let state = 'A';
let requestArr = [];

runTask('B', 2000); // should return after 2 sec
runTask('C', 1000); // should return after 1 sec

async function runTask(fakeReturnValue, fakeReturnDelay) {
  state = await someApi(fakeReturnValue, fakeReturnDelay);
  // when the state is updated, console.log
  console.log(state);
}

async function someApi(fakeReturnValue, fakeReturnDelay) {
  /* find all xhr with requestType "stateupdater"
     and call abort() on them */
  requestArr.filter(xhr => xhr.requestType == 'stateupdater').forEach(xhr => xhr.abort());
  // remove all the xhr requestType "stateupdater"
  requestArr = requestArr.filter(xhr => xhr.requestType != 'stateupdater');
  // create a new Promise
  return new Promise(function(resolve, reject) {
    const xhr = new XMLHttpRequest();
    xhr.addEventListener('load', e => {
      /* remove the current xhr (e.target)
         from the requestArr */
      requestArr = requestArr.filter(xhr => xhr != e.target);
      // return the resonse in the Promise
      resolve(e.target.response);
    });
    // set a custom property on xhr
    xhr.requestType = 'stateupdater';
    // URL is replaced by a data URL for testing
    xhr.open("GET", `data:text/plain,${fakeReturnValue}`);
    // add the new xhr to the requestArr
    requestArr.push(xhr);
    // send the request, here combined with a setTimeout for testing
    //xhr.send();
    setTimeout(function(){xhr.send();}, fakeReturnDelay);
  });
}

This is the old fashioned solution to handle race conditions:

  • Use a global counter that is incremented before sending asynchronous requests
  • Use the counter to "stamp" the requests
  • Inside the request callback (which fire in arbitrary order) compare the stamp with the counter
  • Proceed if the callback was for the latest request

Below is a little proof-of-concept which uses a PHP script to simulate race condition. Notice that the runTask function is supposed to return B and C in execution order but returns C and B in completion order. However, the race condition handling ensures that the state changes from A to C:

let state = "A";
let requestCounter = 0;
async function runTask(url) {
    console.log(`runTask was called (url = ${url})`);
    let currentRequest = ++requestCounter;
    let response = await fetch(url);
    let value = await response.json();
    if (currentRequest !== requestCounter) {
        console.log(`Ignoring invocation (url = ${url})`);
        return;
    }
    console.log(`Updating state (url = ${url})`);
    state = value;
}
runTask("http://localhost/api.php?delay=5&state=B");
runTask("http://localhost/api.php?delay=3&state=C");

Output:

17:00:00.000 runTask was called (url = http://localhost/api.php?delay=5&state=B)
17:00:00.004 runTask was called (url = http://localhost/api.php?delay=3&state=C)
17:00:03.032 Updating state (url = http://localhost/api.php?delay=3&state=C)
17:00:05.041 Ignoring invocation (url = http://localhost/api.php?delay=5&state=B)

There are quite a few ways to achieve this. In my answer I will be using a closure with a counter. As i believe this is the easiest way to implement it.

First I'll define the problem by creating an example of the problem. As can clearly be seen in the example below the state starts off empty. Then gets set with a call count of 2 before getting reset with the call count of 1 from the first api request. This is the undesirable behavior.

An example of the problem

let state = {}
const returnValue = document.getElementById("return-value")
returnValue.innerHTML = JSON.stringify(state)

//* This is the api, lets assume we do not have access over anything in it */
let apiState = {callCount:0}
async function someApi() {
  console.log("the server got a request");
  apiState.callCount++
  const returnableState = JSON.parse(JSON.stringify(apiState));
  return await new Promise((resolve) =>
            setTimeout(async () => {
              resolve(returnableState);
            }, 8000 - apiState.callCount*3000)
          );
}


// this function calls the api and for convienience places it in the DOM
async function runTask() {
  state = await someApi()
  returnValue.innerHTML = JSON.stringify(state)
}

runTask();
runTask();
<code><pre id="return-value"></pre></code>

Usage of a closure with access to a counter variable can solve this. for simplicity I put the counter in the global scope; however, you could in theory encapsulate it if needed.

To solve this all we are incrementing counter variable (requestCount) and then saving it as a constant (thisRequestId) BEFORE we make the api call.

After the api request has returned, we then check to make sure that there is not a newer resolved api request that has been made. We do this using a variable aptly named currentNewestResolvedCounter

An example of the problem being solved with a counter

let state = {}
const returnValue = document.getElementById("return-value")
returnValue.innerHTML = JSON.stringify(state)

//* This is the api, lets assume we do not have access over anything in it */
let apiState = {callCount:0}
async function someApi() {
  console.log("the server got a request");
  apiState.callCount++
  const returnableState = JSON.parse(JSON.stringify(apiState));
  return await new Promise((resolve) =>
            setTimeout(async () => {
              resolve(returnableState);
            }, 8000 - apiState.callCount*3000)
          );
}

// this function calls the api and for convienience places it in the DOM
let requestCount = 0;
// newer is higher
let currentNewestResolvedCounter = 0;
async function runTask() {
  requestCount++;
  const thisRequestId = requestCount
  
  const newState = await someApi()
  
  // make sure that there is not a newer resolved counter 
  // before saying we are the newest and updating the state
  if (currentNewestResolvedCounter < thisRequestId) {
    currentNewestResolvedCounter = thisRequestId
    returnValue.innerHTML = JSON.stringify(newState);
    state = newState;
  }
  console.log({ requestCount, thisRequestId, currentNewestResolvedCounter })
}

runTask()
runTask()
<code><pre id="return-value"></pre></code>

As you can see we did not end up updating the DOM in the second example even though it finished AFTER the first API request.

Related