146 lines
4.6 KiB
TypeScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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();
|
|
}
|
|
}
|