release: WRNexusJS 0.3.0
This commit is contained in:
@@ -20,6 +20,9 @@ export interface Job<T = unknown> {
|
||||
runAt: number;
|
||||
/** If set, re-enqueue this job this many ms after each successful run. */
|
||||
repeat?: number;
|
||||
priority: number;
|
||||
idempotencyKey?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type JobHandler<T = unknown> = (job: Job<T>) => void | Promise<void>;
|
||||
@@ -31,6 +34,10 @@ export interface AddOptions {
|
||||
maxAttempts?: number;
|
||||
/** Re-enqueue this job this many ms after each successful run (recurring). */
|
||||
repeat?: number;
|
||||
/** Higher-priority jobs run first when multiple jobs are due. */
|
||||
priority?: number;
|
||||
/** Prevent duplicate queued work with the same stable key. */
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface QueueOptions {
|
||||
@@ -42,6 +49,8 @@ export interface QueueOptions {
|
||||
pollMs?: number;
|
||||
/** Called when a job exhausts its attempts. */
|
||||
onFailed?: (job: Job, error: unknown) => void;
|
||||
/** Maximum jobs executed in one drain. Default: unlimited. */
|
||||
concurrency?: number;
|
||||
/** Clock injection (tests). Default Date.now. */
|
||||
now?: () => number;
|
||||
}
|
||||
@@ -54,18 +63,66 @@ export interface Queue {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
size(): number;
|
||||
get(id: string): Job | undefined;
|
||||
list(name?: string): Job[];
|
||||
cancel(id: string): boolean;
|
||||
}
|
||||
|
||||
export interface JobDefinition<I> {
|
||||
name: string;
|
||||
options?: Omit<AddOptions, "idempotencyKey">;
|
||||
run: JobHandler<I>;
|
||||
}
|
||||
|
||||
export function defineJob<I>(definition: JobDefinition<I>): JobDefinition<I> {
|
||||
return definition;
|
||||
}
|
||||
|
||||
export interface WorkflowStep<I, O> {
|
||||
name: string;
|
||||
run(input: I): O | Promise<O>;
|
||||
}
|
||||
|
||||
export function defineWorkflow<T>(name: string, steps: Array<WorkflowStep<any, any>>) {
|
||||
return {
|
||||
name,
|
||||
steps,
|
||||
async run(input: T): Promise<unknown> {
|
||||
let value: unknown = input;
|
||||
for (const step of steps) value = await step.run(value);
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function cronToInterval(cron: string): number {
|
||||
const aliases: Record<string, number> = {
|
||||
"@hourly": 60 * 60 * 1000,
|
||||
"@daily": 24 * 60 * 60 * 1000,
|
||||
"@weekly": 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
if (aliases[cron]) return aliases[cron];
|
||||
const everyMinutes = /^\*\/(\d+)\s+\*\s+\*\s+\*\s+\*$/.exec(cron.trim());
|
||||
if (everyMinutes) return Number(everyMinutes[1]) * 60 * 1000;
|
||||
throw new Error(`WRN-CRON-UNSUPPORTED: '${cron}'. Use @hourly, @daily, @weekly, or */N * * * *.`);
|
||||
}
|
||||
|
||||
export function createQueue(options: QueueOptions = {}): Queue {
|
||||
const defaultMax = options.maxAttempts ?? 3;
|
||||
const backoffMs = options.backoffMs ?? 1000;
|
||||
const pollMs = options.pollMs ?? 250;
|
||||
const concurrency = options.concurrency ?? Number.POSITIVE_INFINITY;
|
||||
if (!Number.isInteger(defaultMax) || defaultMax < 1)
|
||||
throw new RangeError("queue maxAttempts must be a positive integer");
|
||||
if (!Number.isFinite(backoffMs) || backoffMs < 0)
|
||||
throw new RangeError("queue backoffMs must be a non-negative number");
|
||||
if (!Number.isFinite(pollMs) || pollMs < 1)
|
||||
throw new RangeError("queue pollMs must be at least 1ms");
|
||||
if (!(
|
||||
concurrency === Number.POSITIVE_INFINITY ||
|
||||
(Number.isInteger(concurrency) && concurrency > 0)
|
||||
))
|
||||
throw new RangeError("queue concurrency must be a positive integer");
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const jobs: Job[] = [];
|
||||
@@ -100,7 +157,10 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
draining = true;
|
||||
try {
|
||||
const cutoff = at ?? now();
|
||||
const due = jobs.filter((j) => j.runAt <= cutoff && handlers.has(j.name));
|
||||
const due = jobs
|
||||
.filter((j) => j.runAt <= cutoff && handlers.has(j.name))
|
||||
.sort((a, b) => b.priority - a.priority || a.runAt - b.runAt || a.createdAt - b.createdAt)
|
||||
.slice(0, concurrency);
|
||||
await Promise.all(due.map(runJob));
|
||||
return due.length;
|
||||
} finally {
|
||||
@@ -120,14 +180,24 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
throw new RangeError("job delayMs must be a non-negative number");
|
||||
if (opts.repeat !== undefined && (!Number.isFinite(opts.repeat) || opts.repeat <= 0))
|
||||
throw new RangeError("job repeat must be a positive number");
|
||||
if (opts.priority !== undefined && !Number.isFinite(opts.priority))
|
||||
throw new RangeError("job priority must be a finite number");
|
||||
if (opts.idempotencyKey) {
|
||||
const existing = jobs.find((job) => job.idempotencyKey === opts.idempotencyKey);
|
||||
if (existing) return existing as Job<typeof data>;
|
||||
}
|
||||
const createdAt = now();
|
||||
const job: Job = {
|
||||
id: `job_${++seq}`,
|
||||
name,
|
||||
data,
|
||||
attempts: 0,
|
||||
maxAttempts: opts.maxAttempts ?? defaultMax,
|
||||
runAt: now() + (opts.delayMs ?? 0),
|
||||
runAt: createdAt + (opts.delayMs ?? 0),
|
||||
repeat: opts.repeat,
|
||||
priority: opts.priority ?? 0,
|
||||
idempotencyKey: opts.idempotencyKey,
|
||||
createdAt,
|
||||
};
|
||||
jobs.push(job);
|
||||
return job as Job<typeof data>;
|
||||
@@ -145,5 +215,13 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
timer = null;
|
||||
},
|
||||
size: () => jobs.length,
|
||||
get: (id) => jobs.find((job) => job.id === id),
|
||||
list: (name) => jobs.filter((job) => !name || job.name === name).map((job) => ({ ...job })),
|
||||
cancel(id) {
|
||||
const index = jobs.findIndex((job) => job.id === id);
|
||||
if (index < 0) return false;
|
||||
jobs.splice(index, 1);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user