Skip to the code
cf.

Task scheduler with delays

MediumTiming

Write createScheduler(). It returns an object with one method, add(task, delay), that schedules task (a function called with no arguments) to run delay ms later, and returns a promise of what task returns.

Tasks run one at a time, in the order they were added. A task's delay starts when the task before it has finished; if that task returned a promise, when the promise has settled. When nothing is running or waiting, the delay starts right away.

If a task throws or returns a rejected promise, the promise from its add rejects with that error, and the next task runs as usual. If it returns a promise that resolves, the promise from add resolves with that value.

const scheduler = createScheduler();
scheduler.add(() => console.log("a"), 1000); // logs "a" at 1000 ms
scheduler.add(() => console.log("b"), 500); // logs "b" at 1500 ms
scheduler.add(() => console.log("c"), 0); // logs "c" at 1500 ms, after "b"

What the tests check

  1. runs a task after its delay
    const clock = useFakeTimers();
    const scheduler = createScheduler();
    const task = spy();
    scheduler.add(task, 100);
    await clock.tickAsync(99);
    expect(task).toHaveBeenCalledTimes(0);
    await clock.tickAsync(1);
    expect(task).toHaveBeenCalledTimes(1);
    
  2. starts each delay when the previous task finishes, in order
    const clock = useFakeTimers();
    const start = clock.now();
    const log = [];
    const scheduler = createScheduler();
    scheduler.add(() => log.push("a at " + (clock.now() - start)), 100);
    scheduler.add(() => log.push("b at " + (clock.now() - start)), 50);
    scheduler.add(() => log.push("c at " + (clock.now() - start)), 0);
    await clock.tickAsync(1000);
    expect(log).toEqual(["a at 100", "b at 150", "c at 150"]);
    
  3. waits for a task's promise before starting the next delay
    const clock = useFakeTimers();
    const start = clock.now();
    const log = [];
    const scheduler = createScheduler();
    scheduler.add(() => new Promise((resolve) => setTimeout(resolve, 200)), 100);
    scheduler.add(() => log.push(clock.now() - start), 100);
    await clock.tickAsync(1000);
    expect(log).toEqual([400]);
    
  4. starts the delay right away when it's idle
    const clock = useFakeTimers();
    const start = clock.now();
    const log = [];
    const scheduler = createScheduler();
    scheduler.add(() => log.push(clock.now() - start), 100);
    await clock.tickAsync(500);
    scheduler.add(() => log.push(clock.now() - start), 100);
    await clock.tickAsync(1000);
    expect(log).toEqual([100, 600]);
    
  5. resolves each add() with its task's result
    const clock = useFakeTimers();
    const scheduler = createScheduler();
    let results;
    Promise.all([scheduler.add(() => "sync", 10), scheduler.add(async () => "async", 10)]).then((values) => {
      results = values;
    }, () => {});
    await clock.tickAsync(100);
    expect(results).toEqual(["sync", "async"]);
    
  6. rejects a failing task's promise and keeps going
    const clock = useFakeTimers();
    const scheduler = createScheduler();
    let error;
    let result;
    scheduler
      .add(() => {
        throw new TypeError("broken");
      }, 100)
      .catch((reason) => {
        error = reason;
      });
    scheduler.add(() => "still runs", 100).then((value) => {
      result = value;
    }, () => {});
    await clock.tickAsync(1000);
    expect(error).toBeInstanceOf(TypeError);
    expect(result).toBe("still runs");
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).