/* Lightweight scheduler for running arbitrary async work on a dynamically changing interval. Features - Dynamic delay: provide a function to compute the next delay based on last run result - Backoff helpers: exponential or custom strategies via a pluggable calculator - Jitter: optional full or bounded jitter to avoid thundering herds - Controls: start, pause, resume, runNow, resetBackoff, stop - Error-aware: distinguish success/failure for adaptive schedules Use cases - Network backoff retry loop - Background autosave with adaptive cadence - Heartbeat/health polling with jitter Design notes - The scheduler never overlaps runs; if the task is still running, the next tick is queued after completion - All public methods are safe to call multiple times */ export type Millis = number; export type RunOutcome = { ok: boolean; error?: unknown; durationMs: Millis; }; export type TaskFn = () => Promise | void; export type NextDelayInput = { lastDelayMs: Millis | null; lastOutcome: RunOutcome | null; runCount: number; // completed runs }; export type NextDelayFn = (input: NextDelayInput) => Millis; export type JitterKind = "none" | "full" | "bounded"; export type JitterOptions = | { kind: "none" } | { kind: "full" } | { kind: "bounded"; percentage: number }; // e.g. 0.2 => ±20% export type BackoffStrategy = { // Given context, returns the base (pre-jitter) delay for the next run nextDelayMs: NextDelayFn; // Optional hook to reset internal state (e.g., after success) reset?: () => void; }; export type DynamicIntervalOptions = { // Task to be executed on each tick task: TaskFn; // Strategy to compute next delay (before jitter). If omitted, constant interval is required. strategy?: BackoffStrategy; // Constant base delay used if no strategy is provided baseDelayMs?: Millis; // Add jitter on top of computed delay jitter?: JitterOptions; // Start immediately when constructed autoStart?: boolean; // Minimum and maximum bounds applied after jitter minDelayMs?: Millis; maxDelayMs?: Millis; // If true, the first run happens immediately; otherwise it waits for the first computed delay runFirstImmediately?: boolean; // Optional signal for cooperative cancellation signal?: AbortSignal; // Optional name for debugging/logging name?: string; }; function clamp(value: number, min?: number, max?: number): number { if (min != null && value < min) return min; if (max != null && value > max) return max; return value; } function applyJitter(baseDelayMs: Millis, jitter?: JitterOptions): Millis { if (!jitter || jitter.kind === "none") return baseDelayMs; if (jitter.kind === "full") { // random in [0, base] return Math.floor(Math.random() * baseDelayMs); } if (jitter.kind === "bounded") { // bounded ±percentage const pct = Math.max(0, Math.min(1, jitter.percentage)); const delta = baseDelayMs * pct; const min = baseDelayMs - delta; const max = baseDelayMs + delta; return Math.floor(min + Math.random() * (max - min)); } return baseDelayMs; } export class DynamicInterval { private readonly task: TaskFn; private readonly strategy?: BackoffStrategy; private readonly baseDelayMs?: Millis; private readonly jitter?: JitterOptions; private readonly minDelayMs?: Millis; private readonly maxDelayMs?: Millis; private readonly runFirstImmediately: boolean; private readonly name?: string; private timer: ReturnType | null = null; private paused = true; private running = false; private stopped = false; private lastDelayMs: Millis | null = null; private lastOutcome: RunOutcome | null = null; private runCount = 0; private readonly abortSignal?: AbortSignal; constructor(options: DynamicIntervalOptions) { const { task, strategy, baseDelayMs, jitter, minDelayMs, maxDelayMs, autoStart, runFirstImmediately, signal, name, } = options; if (!strategy && (baseDelayMs == null || baseDelayMs < 0)) { throw new Error("Provide either strategy or non-negative baseDelayMs"); } this.task = task; this.strategy = strategy; this.baseDelayMs = baseDelayMs; this.jitter = jitter; this.minDelayMs = minDelayMs; this.maxDelayMs = maxDelayMs; this.runFirstImmediately = !!runFirstImmediately; this.name = name; this.abortSignal = signal; if (this.abortSignal) { this.abortSignal.addEventListener("abort", () => this.stop(), { once: true, }); } if (autoStart) this.start(); } // Public API start(): void { if (this.stopped) return; // cannot restart after stop if (!this.paused) return; // already started this.paused = false; if (this.runFirstImmediately) { this.queueImmediate(); } else { this.scheduleNext(); } } pause(): void { if (this.paused || this.stopped) return; this.paused = true; this.clearTimer(); } resume(): void { if (!this.paused || this.stopped) return; this.paused = false; this.scheduleNext(); } stop(): void { if (this.stopped) return; this.stopped = true; this.paused = true; this.clearTimer(); } runNow(): void { if (this.stopped) return; if (this.running) return; // avoid overlap; when current completes it will schedule this.clearTimer(); this.queueImmediate(); } resetBackoff(): void { this.lastDelayMs = null; this.lastOutcome = null; if (this.strategy && this.strategy.reset) this.strategy.reset(); } get info() { return { name: this.name, paused: this.paused, stopped: this.stopped, running: this.running, lastDelayMs: this.lastDelayMs, lastOutcome: this.lastOutcome, runCount: this.runCount, } as const; } // Internals private clearTimer() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } private queueImmediate() { // Using setTimeout 0 to avoid reentrancy hazards this.timer = setTimeout(() => this.tick(), 0); } private scheduleNext() { if (this.paused || this.stopped) return; const base = this.computeBaseDelay(); const withJitter = applyJitter(base, this.jitter); const bounded = clamp(withJitter, this.minDelayMs, this.maxDelayMs); this.lastDelayMs = bounded; this.timer = setTimeout(() => this.tick(), bounded); } private computeBaseDelay(): Millis { const input: NextDelayInput = { lastDelayMs: this.lastDelayMs, lastOutcome: this.lastOutcome, runCount: this.runCount, }; if (this.strategy) return Math.max(0, this.strategy.nextDelayMs(input)); // constant interval path return Math.max(0, this.baseDelayMs as Millis); } private async tick() { if (this.paused || this.stopped) return; if (this.running) return; // should not happen because we never schedule while running this.running = true; const start = Date.now(); let outcome: RunOutcome; try { await this.task(); outcome = { ok: true, durationMs: Date.now() - start }; // On success, consumers may want to reset backoff state // We don't auto-reset here to allow strategies to encode their own success handling } catch (err) { outcome = { ok: false, error: err, durationMs: Date.now() - start }; } this.running = false; this.lastOutcome = outcome; this.runCount += 1; if (!this.paused && !this.stopped) { this.scheduleNext(); } } } // Strategy helpers export function constantInterval(delayMs: Millis): BackoffStrategy { if (delayMs < 0) throw new Error("delay must be >= 0"); return { nextDelayMs: () => delayMs, }; } export type ExponentialBackoffOptions = { initialDelayMs: Millis; // starting delay maxDelayMs: Millis; // cap factor?: number; // growth factor (default 2) // If true, reset to initialDelayMs after a successful run resetOnSuccess?: boolean; }; export function exponentialBackoff( options: ExponentialBackoffOptions, ): BackoffStrategy { const factor = options.factor ?? 2; let current = clamp(options.initialDelayMs, 0, options.maxDelayMs); return { nextDelayMs: ({ lastOutcome, runCount }) => { // Use initial delay on the very first schedule if (runCount === 0) { return current; } if (lastOutcome?.ok) { // success path handled below depending on resetOnSuccess if (options.resetOnSuccess) { current = options.initialDelayMs; } return current; } // On failure or first run, increase delay const next = Math.min( options.maxDelayMs, Math.max(options.initialDelayMs, current) * factor, ); current = next; return current; }, reset: () => { current = options.initialDelayMs; }, }; } export type LinearBackoffOptions = { initialDelayMs: Millis; stepMs: Millis; maxDelayMs?: Millis; resetOnSuccess?: boolean; }; export function linearBackoff(options: LinearBackoffOptions): BackoffStrategy { let current = Math.max(0, options.initialDelayMs); const max = options.maxDelayMs ?? Number.POSITIVE_INFINITY; return { nextDelayMs: ({ lastOutcome, runCount }) => { // Use initial delay on first schedule if (runCount === 0) return current; if (lastOutcome?.ok && options.resetOnSuccess) { current = options.initialDelayMs; return current; } current = Math.min(max, current + options.stepMs); return current; }, reset: () => { current = options.initialDelayMs; }, }; } /* JSDoc examples // 1) Exponential backoff with jitter for retry loop const scheduler = new DynamicInterval({ task: async () => { // do work that may fail }, strategy: exponentialBackoff({ initialDelayMs: 500, maxDelayMs: 30_000, resetOnSuccess: true }), jitter: { kind: "bounded", percentage: 0.2 }, minDelayMs: 100, maxDelayMs: 60_000, autoStart: true, name: "retry-loop", }); // 2) Autosave every 5s with full jitter to spread load const autosave = new DynamicInterval({ task: saveDraft, strategy: constantInterval(5_000), jitter: { kind: "full" }, autoStart: true, name: "autosave", }); // 3) Heartbeat that runs immediately, then every 10s; supports pause/resume const heartbeat = new DynamicInterval({ task: sendHeartbeat, baseDelayMs: 10_000, runFirstImmediately: true, }); heartbeat.start(); // later heartbeat.pause(); heartbeat.resume(); */