release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createExecutionContext, type ExecutionContext } from "@wrnexus/core";
|
||||
import type { AddOptions, Job, JobHandler } from "./index.ts";
|
||||
|
||||
/**
|
||||
@@ -55,16 +56,21 @@ export interface DurableQueueOptions {
|
||||
workerId?: string;
|
||||
maxAttempts?: number;
|
||||
concurrency?: number;
|
||||
capacity?: number;
|
||||
leaseMs?: number;
|
||||
backoff?: (attempt: number) => number;
|
||||
now?: () => number;
|
||||
onDeadLetter?: (job: Job, error: unknown) => void | Promise<void>;
|
||||
context?: (job: Job, signal: AbortSignal) => ExecutionContext;
|
||||
}
|
||||
|
||||
export interface DurableQueue {
|
||||
add<T>(name: string, data: T, options?: AddOptions): Promise<Job<T>>;
|
||||
process<T>(name: string, handler: JobHandler<T>): void;
|
||||
drain(): Promise<number>;
|
||||
cancel(id: string): Promise<boolean>;
|
||||
shutdown(options?: { force?: boolean }): Promise<void>;
|
||||
list(name?: string): Promise<Job[]>;
|
||||
failed(): Job[];
|
||||
retry(id: string): Promise<boolean>;
|
||||
}
|
||||
@@ -91,11 +97,21 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
||||
const workerId = options.workerId?.trim() || `worker-${crypto.randomUUID()}`;
|
||||
const defaultMaxAttempts = positiveInteger(options.maxAttempts ?? 3, "queue maxAttempts");
|
||||
const concurrency = positiveInteger(options.concurrency ?? 10, "queue concurrency");
|
||||
const capacity = positiveInteger(options.capacity ?? 10_000, "queue capacity");
|
||||
const leaseMs = positiveInteger(options.leaseMs ?? 30_000, "queue leaseMs");
|
||||
let sequence = 0;
|
||||
let draining = false;
|
||||
let accepting = true;
|
||||
const active = new Map<string, { controller: AbortController; promise: Promise<boolean> }>();
|
||||
|
||||
async function runJob(job: Job): Promise<boolean> {
|
||||
function beginJob(job: Job): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const promise = runJob(job, controller).finally(() => active.delete(job.id));
|
||||
active.set(job.id, { controller, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function runJob(job: Job, controller: AbortController): Promise<boolean> {
|
||||
const handler = handlers.get(job.name);
|
||||
if (!handler) return false;
|
||||
|
||||
@@ -106,7 +122,16 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
||||
job.attempts += 1;
|
||||
|
||||
try {
|
||||
await handler(job);
|
||||
await handler(job, {
|
||||
signal: controller.signal,
|
||||
execution:
|
||||
options.context?.(job, controller.signal) ??
|
||||
createExecutionContext({
|
||||
kind: job.repeat ? "cron" : "queue",
|
||||
signal: controller.signal,
|
||||
metadata: { jobId: job.id, jobName: job.name, attempt: job.attempts },
|
||||
}),
|
||||
});
|
||||
|
||||
if (job.repeat && job.repeat > 0) {
|
||||
job.attempts = 0;
|
||||
@@ -116,6 +141,10 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
||||
await store.remove(job.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
await store.remove(job.id);
|
||||
return true;
|
||||
}
|
||||
if (job.attempts < job.maxAttempts) {
|
||||
const delay = nonNegativeNumber(
|
||||
options.backoff?.(job.attempts) ?? Math.min(60_000, 1_000 * 2 ** (job.attempts - 1)),
|
||||
@@ -135,6 +164,7 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
||||
|
||||
return {
|
||||
async add<T>(name: string, data: T, add: AddOptions = {}) {
|
||||
if (!accepting) throw new Error("WRN-QUEUE-CLOSED: queue is shutting down");
|
||||
if (!name.trim()) throw new TypeError("queue job name cannot be empty");
|
||||
|
||||
const maxAttempts = positiveInteger(add.maxAttempts ?? defaultMaxAttempts, "job maxAttempts");
|
||||
@@ -153,6 +183,9 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
||||
);
|
||||
if (existing) return existing as Job<T>;
|
||||
}
|
||||
if ((await store.list()).length >= capacity) {
|
||||
throw new Error(`WRN-QUEUE-CAPACITY: queue capacity of ${capacity} reached`);
|
||||
}
|
||||
|
||||
const createdAt = now();
|
||||
const job: Job<T> = {
|
||||
@@ -181,7 +214,7 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
||||
draining = true;
|
||||
try {
|
||||
const due = await store.due(now(), concurrency);
|
||||
const results = await Promise.all(due.map(runJob));
|
||||
const results = await Promise.all(due.map(beginJob));
|
||||
return results.filter(Boolean).length;
|
||||
} finally {
|
||||
draining = false;
|
||||
@@ -192,6 +225,29 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
||||
return [...deadLetters.values()].map((job) => structuredClone(job));
|
||||
},
|
||||
|
||||
list(name) {
|
||||
return store.list(name);
|
||||
},
|
||||
|
||||
async cancel(id) {
|
||||
const running = active.get(id);
|
||||
if (running) {
|
||||
running.controller.abort();
|
||||
return true;
|
||||
}
|
||||
if (!(await store.get(id))) return false;
|
||||
await store.remove(id);
|
||||
return true;
|
||||
},
|
||||
|
||||
async shutdown(shutdownOptions = {}) {
|
||||
accepting = false;
|
||||
if (shutdownOptions.force) {
|
||||
for (const { controller } of active.values()) controller.abort();
|
||||
}
|
||||
await Promise.allSettled([...active.values()].map(({ promise }) => promise));
|
||||
},
|
||||
|
||||
async retry(id) {
|
||||
const job = deadLetters.get(id);
|
||||
if (!job) return false;
|
||||
|
||||
Reference in New Issue
Block a user