Search code examples
asynchronousweb-workerjob-queue

Implementing sequential job queue using React


I am looking to implement a job queue that ensures the response from an API is returned in the order of input items entered even in spite of each API call taking a variable amount of time potentially.

See codesandbox here https://codesandbox.io/s/sequential-api-response-eopue - When I input item such as 1, 12, 1234, 12345 in the input field and hit Enter, it goes to a simulated backend where I return item+-response to signify the output of corresponding input. However, I have used a different timeout on each call using Math.random() to simulate a real-world scenario where the API could take a non-deterministic amount of time.

Current output

processing:  1 
processing:  12 
processing:  123 
processing:  1234 
processing:  12345 
processing:  123456 
response: 1234-response 
response: 12-response 
response: 123456-response 
response: 123-response 
response: 1-response 
response: 12345-response 

Expected output The output I'd like to see is

processing:  1 
processing:  12 
processing:  123 
processing:  1234 
processing:  12345 
processing:  123456 
response: 1-response 
response: 12-response 
response: 123-response 
response: 1234-response 
response: 12345-response 
response: 123456-response 

My attempt: I have tried to implement the function getSequentialResponse (which is a wrapper over the function getNonSequentialResponse that generates the incorrect output above). This function adds the item that the user enters to a queue and does queue.shift() only when the lock variable _isBusy is released by getNonSequentialResponse indicating that the current promise has resolved and its ready to process the next. Until then, it waits in a while loop while the current item is being processed. My thinking was that since elements are always removed the head, the items will be processed in the order in which they were input.

Error: However, this, as I understood is the wrong approach since the UI thread is waiting and results in the error Potential infinite loop: exceeded 10001 iterations. You can disable this check by creating a sandbox.config.json file.


Solution

  • A couple of things to consider here.

    1. The while loop is the wrong approach here - since we're working with asynchronous operations in JavaScript we need to keep in mind how the event loop works (here's a good talk if you need a primer). Your while loop will tie up the call stack and prevent the rest of the event loop (which includes the ES6 job queue, where Promises are dealt with, and the callback queue, where timeouts are dealt with) from occurring.
    2. So without a while loop, is there a way in JavaScript that we can control when to resolve a function so we can move onto the next one? Of course - it's Promises! We'll wrap the job in a Promise and only resolve that Promise when we're ready to move forward or reject it if there's an error.
    3. Since we're talking about a specific data structure, a queue, let's use some better terms to improve our mental model. We're not "processing" these jobs, we're "enqueuing" them. If we were processing them at the same time (i.e. "processing 1", "processing 2", etc.), we wouldn't be executing them sequentially.
    export default class ItemProvider {
      private _queue: any;
      private _isBusy: boolean;
    
      constructor() {
        this._queue = [];
        this._isBusy = false;
      }
    
      public enqueue(job: any) {
        console.log("Enqueing", job);
        // we'll wrap the job in a promise and include the resolve 
        // and reject functions in the job we'll enqueue, so we can 
        // control when we resolve and execute them sequentially
        new Promise((resolve, reject) => {
          this._queue.push({ job, resolve, reject });
        });
        // we'll add a nextJob function and call it when we enqueue a new job;
        // we'll use _isBusy to make sure we're executing the next job sequentially
        this.nextJob();
      }
    
      private nextJob() {
        if (this._isBusy) return;
        const next = this._queue.shift();
        // if the array is empty shift() will return undefined
        if (next) {
          this._isBusy = true;
          next
            .job()
            .then((value: any) => {
              console.log(value);
              next.resolve(value);
              this._isBusy = false;
              this.nextJob();
            })
            .catch((error: any) => {
              console.error(error);
              next.reject(error);
              this._isBusy = false;
              this.nextJob();
            });
        }
      }
    }
    

    Now in our React code, we'll just make a fake async function using that helper function you made and enqueue the job!

    import "./styles.css";
    import ItemProvider from "./ItemProvider";
    // import { useRef } from "react";
    
    // I've modified your getNonSequentialResponse function as a helper 
    // function to return a fake async job function that resolves to our item
    const getFakeAsyncJob = (item: any) => {
      const timeout = Math.floor(Math.random() * 2000) + 500;
      // const timeout = 0;
      return () =>
        new Promise((resolve) => {
          setTimeout(() => {
            resolve(item + "-response");
          }, timeout);
        });
    };
    
    export default function App() {
      const itemProvider = new ItemProvider();
    
      function keyDownEventHandler(ev: KeyboardEvent) {
        if (ev.keyCode === 13) {
          const textFieldValue = (document.getElementById("textfieldid") as any)
            .value;
    
          // not sequential
          // itemProvider.getNonSequentialResponse(textFieldValue).then((response) => {
          //   console.log("response: " + response);
          // });
          
          // we make a fake async function tht resolves to our textFieldValue
          const myFakeAsyncJob = getFakeAsyncJob(textFieldValue);
          // and enqueue it 
          itemProvider.enqueue(myFakeAsyncJob);
        }
      }
    
      return (
        <div className="App">
          <input
            id="textfieldid"
            placeholder={"Type and hit Enter"}
            onKeyDown={keyDownEventHandler}
            type="text"
          />
    
          <div className="displaylabelandbox">
            <label>Display box below</label>
            <div className="displaybox">hello</div>
          </div>
        </div>
      );
    }
    

    Here's the codesandbox.