DEV Community

Luka Vidaković
Luka Vidaković

Posted on

Implementing scheduler's stop mechanism

In previous post, we untangled our pause and loop mechanisms but the main flaw of not being able to stop the loop remained. Now we are going to rectify this. From previous code:

const pause = time => new Promise(resolve => setTimeout(resolve, time));

async function* cycle(time) {
  while (true) {
    yield pause(time);
  }
}

async function runPeriodically(callback, time) {
  for await (let tick of cycle(time)) {
    await callback();
  }
}
Enter fullscreen mode Exit fullscreen mode

And it's usage to log current time every 2 seconds:

function logTime() {
  const time = new Date();
  console.log(time.toLocaleTimeString());
}

runPeriodically(logTime, 2000);

Enter fullscreen mode Exit fullscreen mode

We'll need to transfer to a bit more complex constructs. We need to keep track of state to be able to gracefully stop. There are 2 approaches to encapsulate all the state and functionality: using some kind of factory function or a class. I'll stick with the factory function because I don't want to worry about this, new, what is public etc. Our code transforms into this:

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

  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() {
    for await (let tick of cycle(time)) {
      await callback();
    }
  }

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

  return {
    runPeriodically,
    stop
  };
}
Enter fullscreen mode Exit fullscreen mode

createScheduler factory function expects object with 2 parameters, callback function that is executed at each cycle/iteration and time parameter that defines the duration of a cycle. The same parameters we previously sent to runPeriodically function. 2 local variables are used to gracefully stop the loop: run and timeoutId. When run switches to false, it's a signal to generator function to exit. Exit from the generator function in turn also causes our "for await" loop inside runPeriodically function to finish. Function that controls everything needed for a graceful stop is of course stop. It clears pending timeout(if any) and sets a flag to signal a termination of a generator's while loop. At the end of our factory function we return an object containing functions accessible from the outside code, a function to run the loop and a function to stop it.

Great! We now have a way to run and stop the loop whenever needed. But we have a new problem 😅. We probably want to handle only a single loop at any given time for a single scheduler instance. If someone called runPeriodically a couple of times in a row, this would start parallel but out-of-sync loops that share a common state used for termination. Not good! The cause of the issue is within a "for await" loop definition:

for await (let tick of cycle(time)) {
  await callback();
}
Enter fullscreen mode Exit fullscreen mode

cycle(time) creates a new iterator every time runPeriodically is called and the easiest way to fix this is to simply share this iterator through a common state. Just like with termination variables, and initialize a new one only if iterator is not yet created or the previous one has finished. To put it into code:

function createScheduler({ callback, time }) {
  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) {
      await callback();
    }
  }

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

  return {
    runPeriodically,
    stop
  };
}
Enter fullscreen mode Exit fullscreen mode

Main features are covered. We can:

  • create an instance of a scheduler
  • define a function we want to run on every cycle(callback) and a duration of a cycle
  • run and stop the loop whenever we see fit
  • additionally, runPeriodically resolves it's Promise when the loop is stopped which can be helpful

Example of usage:

const scheduler = createScheduler({ callback: logTime, time: 2000 });
scheduler.runPeriodically();

// at any point in time we can halt the loop using
scheduler.stop()

// or restart it
scheduler.runPeriodically();
Enter fullscreen mode Exit fullscreen mode

Pretty neat, but we can't know how long the callback function will execute. It's provided from somewhere outside and it's unsafe to wait for it indefinitely to finish. We need a timeout mechanism if we want to avoid our code hanging at some point because of an external function failing or taking long time to finish. Fortunately, this can be achieved in a pretty elegant way using Promise API and we'll see how in the next, shorter post 😅. Thanks for reading!

Top comments (0)