DEV Community 👩‍💻👨‍💻

Luka Vidaković
Luka Vidaković

Posted on

Exponential backoff logic for a code scheduler

We have our scheduler factory from a previous post:

function createScheduler({ callback, time, callbackTimeout }) {
  let run;
  let timeoutId;
  let ticker;

  function pause(time) {
    return new Promise(resolve => {
      timeoutId = setTimeout(resolve, time);
    });
  }

  async function* cycle(time) {
    run = true;

    while (run) {
      yield pause(time);
    }
  }

  async function runPeriodically() {
    if (!ticker || !run) {
      ticker = cycle(time);
    }

    for await (let tick of ticker) {
      if (callbackTimeout) {
        await Promise.race([callback(), pause(callbackTimeout)]);
      } else {
        await callback();
      }
    }
  }

  function stop() {
    clearTimeout(timeoutId);
    run = false;
  }

  return {
    runPeriodically,
    stop
  };
}

It creates a scheduler that runs a specified function periodically in defined time intervals. Final thing we are going to add is an exponential backoff logic, for cases when callback function fails or timeouts. The feedback(error) will be provided through communication with an iterator object. In case we send an error, it'll start to prolong the cycle duration. Otherwise, it'll shorten it. To do this we'll need to:

  • update a generator function to keep track of current wait time and based on error received back, prolong the wait duration if error is received, shorten it otherwise
  • swap "for await" loop using a while loop so we can pass values back to the iterator
  • throw an error on a callback timeout, so we can handle it like any other error happening inside the callback itself
  • add new configuration parameters to a scheduler factory function so we can control the exponential backoff multiplier, and set a maximum allowed cycle duration

Since this is starting to add up and is becoming harder to follow I'll just paste the final code here and in a offer a codesandbox usage example at the end of the post so you can test it out:

Final code:

function createScheduler({
  callback,
  time,
  callbackTimeout,
  backoffMultiplier = 1.5,
  backoffMaxTime = 20000
}) {
  let run;
  let timeoutId;
  let ticker;

  function pause(time) {
    return new Promise(resolve => {
      timeoutId = setTimeout(resolve, time);
    });
  }

  async function* cycle(time) {
    let currentWaitTime = time;
    run = true;

    while (run) {
      const error = yield pause(currentWaitTime);
      if (error) {
        currentWaitTime = Math.min(
          currentWaitTime * backoffMultiplier,
          backoffMaxTime
        );
      } else {
        currentWaitTime = Math.max(currentWaitTime / backoffMultiplier, time);
      }
    }
  }

  async function runPeriodically() {
    if (!ticker || !run) {
      ticker = cycle(time);
    }

    let item = await ticker.next();

    while (!item.done) {
      try {
        if (callbackTimeout) {
          await Promise.race([
            callback(),
            pause(callbackTimeout).then(() => {
              throw new Error("Callback timeout");
            })
          ]);
        } else {
          await callback();
        }
        item = await ticker.next();
      } catch (error) {
        item = await ticker.next(error);
      }
    }
  }

  function stop() {
    clearTimeout(timeoutId);
    run = false;
  }

  return {
    runPeriodically,
    stop
  };
}

What this periodic scheduler logic allows us to do:

  • create an instance of a scheduler which can run an (a)sync function(callback) periodically in given time intervals
  • run and stop the loop whenever we see fit
  • runPeriodically resolves it's Promise when the loop is stopped which can be helpful
  • if callbackTimeout parameter is specified code will take care that waiting for callback to resolve won't go over our time budget
  • if callback fails or a timeout happens, the error is fed back to the iterator which increases the duration of a cycle using exponential backoff logic. If no error is returned duration is decreased one step at a time to our original specified duration

Here is a usage example where we set doSomething function to run every 2 seconds and give it 1 second of time to execute(try to uncomment the return line inside logTime function). If doSomething function fails or encounters a timeout, cycle duration is multiplied with backoffMultiplier to set a new cycle duration. And this happens until duration reaches at most backoffMaxTime number(20 seconds). If cycles go without an error, duration will be lowered using a multiplier towards the original cycle duration. setTimeout at the end kills the processing loop after 60 seconds.

Hopefully this series brought some new ideas and perspectives around working with asynchronous code and scheduling. Scheduler as it is, may not be the best and for every use-case but it serves as an example of how flexible Javascript's asynchronous syntax can be. From one-liner pause function implemented with a Promise and setTimeout to solid periodic code scheduler in just over 60 lines of code.

Top comments (0)

🌚 Life is too short to browse without dark mode