Files
WRNexusJS/packages/queue/src/scheduler.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

146 lines
4.6 KiB
TypeScript

import type { AddOptions, Job } from "./index.ts";
import type { DurableQueue } from "./durable.ts";
export interface ScheduledJob<T = unknown> {
name: string;
data: T;
everyMs: number;
options?: AddOptions;
}
export interface QueueScheduler {
start(): void;
stop(): void;
tick(now?: number): Promise<number>;
snapshot(): { running: boolean; schedules: number; nextRuns: Record<string, number> };
}
/** Restart-safe scheduler when used with a durable queue and stable idempotency buckets. */
export function createQueueScheduler(
queue: DurableQueue,
schedules: ScheduledJob[],
options: { pollMs?: number; now?: () => number } = {},
): QueueScheduler {
const now = options.now ?? Date.now;
const pollMs = options.pollMs ?? 1000;
const nextRuns = new Map(schedules.map((schedule) => [schedule.name, now()]));
let timer: ReturnType<typeof setInterval> | null = null;
const tick = async (at = now()) => {
let added = 0;
for (const schedule of schedules) {
if (!Number.isFinite(schedule.everyMs) || schedule.everyMs < 1)
throw new RangeError(`Schedule '${schedule.name}' everyMs must be positive`);
const next = nextRuns.get(schedule.name) ?? at;
if (next > at) continue;
const bucket = Math.floor(at / schedule.everyMs);
await queue.add(schedule.name, schedule.data, {
...schedule.options,
idempotencyKey: schedule.options?.idempotencyKey ?? `schedule:${schedule.name}:${bucket}`,
});
nextRuns.set(schedule.name, (bucket + 1) * schedule.everyMs);
added++;
}
return added;
};
return {
start() {
if (!timer) timer = setInterval(() => void tick(), pollMs);
},
stop() {
if (timer) clearInterval(timer);
timer = null;
},
tick,
snapshot: () => ({
running: timer !== null,
schedules: schedules.length,
nextRuns: Object.fromEntries(nextRuns),
}),
};
}
export async function addBatch<T>(
queue: DurableQueue,
name: string,
values: T[],
options?: AddOptions,
): Promise<Job<T>[]> {
return Promise.all(
values.map((value, index) =>
queue.add(name, value, {
...options,
idempotencyKey: options?.idempotencyKey ? `${options.idempotencyKey}:${index}` : undefined,
}),
),
);
}
export interface QueueDashboardSnapshot {
generatedAt: number;
pending: number;
failed: number;
byName: Record<string, number>;
oldestRunAt?: number;
}
export async function queueDashboardSnapshot(queue: DurableQueue): Promise<QueueDashboardSnapshot> {
const jobs = await queue.list();
return {
generatedAt: Date.now(),
pending: jobs.length,
failed: queue.failed().length,
byName: jobs.reduce<Record<string, number>>((counts, job) => {
counts[job.name] = (counts[job.name] ?? 0) + 1;
return counts;
}, {}),
oldestRunAt: jobs.length ? Math.min(...jobs.map((job) => job.runAt)) : undefined,
};
}
export function renderQueueDashboard(snapshot: QueueDashboardSnapshot): string {
const rows = Object.entries(snapshot.byName)
.sort(([left], [right]) => left.localeCompare(right))
.map(
([name, count]) =>
`<tr><td>${name.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!)}</td><td>${count}</td></tr>`,
)
.join("");
return `<!doctype html><html><head><meta charset="utf-8"><title>WRNexus Queue Dashboard</title></head><body><main><h1>Queue dashboard</h1><p>Pending: ${snapshot.pending} · Failed: ${snapshot.failed}</p><table><thead><tr><th>Queue</th><th>Pending</th></tr></thead><tbody>${rows}</tbody></table></main></body></html>`;
}
/** Long-running scheduler/worker loop suitable for a dedicated process or container. */
export async function runQueueDaemon(
queue: DurableQueue,
scheduler: QueueScheduler,
options: { signal?: AbortSignal; pollMs?: number; onError?: (error: unknown) => void } = {},
): Promise<void> {
const pollMs = options.pollMs ?? 250;
if (!Number.isInteger(pollMs) || pollMs < 10)
throw new RangeError("Daemon pollMs must be at least 10ms");
scheduler.start();
try {
while (!options.signal?.aborted) {
try {
await scheduler.tick();
await queue.drain();
} catch (error) {
options.onError?.(error);
}
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, pollMs);
options.signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
}
} finally {
scheduler.stop();
await queue.shutdown();
}
}