Delay Queue With Scheduled Jobs

Schedule async jobs for future delivery with cancellable timers, early-clock rescheduling and pending inspection.

TypescriptResilience

Code

snippet
export interface DelayedItem<T> {
  id: string;
  value: T;
  dueAt: number;
}

export class DelayQueue<T> {
  private readonly items = new Map<string, DelayedItem<T>>();
  private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
  private readonly handler: (value: T, id: string) => void | Promise<void>;
  private readonly now: () => number;
  private sequence = 0;
  private lastFailure: unknown;

  constructor(
    handler: (value: T, id: string) => void | Promise<void>,
    now: () => number = Date.now,
  ) {
    this.handler = handler;
    this.now = now;
  }

  schedule(value: T, delayMs: number, id = "job-" + (this.sequence += 1)): string {
    if (!Number.isFinite(delayMs) || delayMs < 0) {
      throw new RangeError("delayMs must be a non-negative finite number");
    }
    this.cancel(id);
    const dueAt = this.now() + delayMs;
    this.items.set(id, { id, value, dueAt });
    const timer = setTimeout(() => {
      void this.deliver(id);
    }, delayMs);
    const handle = timer as unknown as { unref?: () => void };
    handle.unref?.();
    this.timers.set(id, timer);
    return id;
  }

  cancel(id: string): boolean {
    const timer = this.timers.get(id);
    if (timer !== undefined) clearTimeout(timer);
    this.timers.delete(id);
    return this.items.delete(id);
  }

  async deliver(id: string): Promise<void> {
    const item = this.items.get(id);
    if (!item) return;
    const remaining = item.dueAt - this.now();
    if (remaining > 0) {
      const timer = setTimeout(() => {
        void this.deliver(id);
      }, remaining);
      const handle = timer as unknown as { unref?: () => void };
      handle.unref?.();
      this.timers.set(id, timer);
      return;
    }
    this.items.delete(id);
    this.timers.delete(id);
    try {
      await this.handler(item.value, id);
    } catch (error) {
      this.lastFailure = error;
    }
  }

  get size(): number {
    return this.items.size;
  }

  get lastError(): unknown {
    return this.lastFailure;
  }

  pending(): DelayedItem<T>[] {
    return [...this.items.values()]
      .sort((left, right) => left.dueAt - right.dueAt)
      .map((item) => ({ ...item }));
  }

  clear(): void {
    for (const id of [...this.items.keys()]) this.cancel(id);
  }
}