release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user