release: WRNexusJS 0.8.0
This commit is contained in:
+104
-16
@@ -25,7 +25,14 @@ export interface Job<T = unknown> {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type JobHandler<T = unknown> = (job: Job<T>) => void | Promise<void>;
|
||||
export interface JobContext {
|
||||
/** Aborted when an active job is cancelled or the queue is force-stopped. */
|
||||
signal: AbortSignal;
|
||||
/** The same trusted context shape used by HTTP, actions, realtime and webhooks. */
|
||||
execution: ExecutionContext;
|
||||
}
|
||||
|
||||
export type JobHandler<T = unknown> = (job: Job<T>, context: JobContext) => void | Promise<void>;
|
||||
|
||||
export interface AddOptions {
|
||||
/** Delay before the job becomes runnable (ms). */
|
||||
@@ -51,8 +58,11 @@ export interface QueueOptions {
|
||||
onFailed?: (job: Job, error: unknown) => void;
|
||||
/** Maximum jobs executed in one drain. Default: unlimited. */
|
||||
concurrency?: number;
|
||||
/** Maximum queued + active jobs. Adds reject once this limit is reached. */
|
||||
capacity?: number;
|
||||
/** Clock injection (tests). Default Date.now. */
|
||||
now?: () => number;
|
||||
context?: (job: Job, signal: AbortSignal) => ExecutionContext;
|
||||
}
|
||||
|
||||
export interface Queue {
|
||||
@@ -62,10 +72,14 @@ export interface Queue {
|
||||
drain(now?: number): Promise<number>;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
/** Stop accepting work and wait for active handlers (or abort them). */
|
||||
shutdown(options?: { force?: boolean }): Promise<void>;
|
||||
size(): number;
|
||||
get(id: string): Job | undefined;
|
||||
list(name?: string): Job[];
|
||||
cancel(id: string): boolean;
|
||||
failed(): Job[];
|
||||
retry(id: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface JobDefinition<I> {
|
||||
@@ -112,6 +126,7 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
const backoffMs = options.backoffMs ?? 1000;
|
||||
const pollMs = options.pollMs ?? 250;
|
||||
const concurrency = options.concurrency ?? Number.POSITIVE_INFINITY;
|
||||
const capacity = options.capacity ?? 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)
|
||||
@@ -123,13 +138,18 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
(Number.isInteger(concurrency) && concurrency > 0)
|
||||
))
|
||||
throw new RangeError("queue concurrency must be a positive integer");
|
||||
if (!(capacity === Number.POSITIVE_INFINITY || (Number.isInteger(capacity) && capacity > 0)))
|
||||
throw new RangeError("queue capacity must be a positive integer");
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const jobs: Job[] = [];
|
||||
const handlers = new Map<string, JobHandler>();
|
||||
const deadLetters = new Map<string, Job>();
|
||||
const active = new Map<string, { controller: AbortController; promise: Promise<void> }>();
|
||||
let seq = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let draining = false;
|
||||
let accepting = true;
|
||||
|
||||
async function runJob(job: Job): Promise<void> {
|
||||
const handler = handlers.get(job.name);
|
||||
@@ -137,19 +157,37 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
const idx = jobs.indexOf(job);
|
||||
if (idx >= 0) jobs.splice(idx, 1); // claim it
|
||||
job.attempts++;
|
||||
try {
|
||||
await handler(job);
|
||||
if (job.repeat && job.repeat > 0) {
|
||||
jobs.push({ ...job, attempts: 0, runAt: now() + job.repeat }); // recurring
|
||||
const controller = new AbortController();
|
||||
const execution = (async () => {
|
||||
try {
|
||||
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) {
|
||||
jobs.push({ ...job, attempts: 0, runAt: now() + job.repeat }); // recurring
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (job.attempts < job.maxAttempts) {
|
||||
job.runAt = now() + backoffMs * Math.pow(2, job.attempts - 1); // exponential backoff
|
||||
jobs.push(job);
|
||||
} else {
|
||||
deadLetters.set(job.id, { ...job });
|
||||
await options.onFailed?.(job, error);
|
||||
}
|
||||
} finally {
|
||||
active.delete(job.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (job.attempts < job.maxAttempts) {
|
||||
job.runAt = now() + backoffMs * Math.pow(2, job.attempts - 1); // exponential backoff
|
||||
jobs.push(job);
|
||||
} else {
|
||||
options.onFailed?.(job, error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
active.set(job.id, { controller, promise: execution });
|
||||
await execution;
|
||||
}
|
||||
|
||||
const drain: Queue["drain"] = async (at) => {
|
||||
@@ -170,6 +208,7 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
|
||||
return {
|
||||
async add(name, data, opts = {}) {
|
||||
if (!accepting) throw new Error("WRN-QUEUE-CLOSED: queue is shutting down");
|
||||
if (!name.trim()) throw new TypeError("queue job name cannot be empty");
|
||||
if (
|
||||
opts.maxAttempts !== undefined &&
|
||||
@@ -186,6 +225,8 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
const existing = jobs.find((job) => job.idempotencyKey === opts.idempotencyKey);
|
||||
if (existing) return existing as Job<typeof data>;
|
||||
}
|
||||
if (jobs.length + active.size >= capacity)
|
||||
throw new Error(`WRN-QUEUE-CAPACITY: queue capacity of ${capacity} reached`);
|
||||
const createdAt = now();
|
||||
const job: Job = {
|
||||
id: `job_${++seq}`,
|
||||
@@ -207,6 +248,7 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
},
|
||||
drain,
|
||||
start() {
|
||||
if (!accepting) throw new Error("WRN-QUEUE-CLOSED: queue is shutting down");
|
||||
if (timer) return;
|
||||
timer = setInterval(() => void drain(), pollMs);
|
||||
},
|
||||
@@ -214,16 +256,62 @@ export function createQueue(options: QueueOptions = {}): Queue {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
},
|
||||
async shutdown(shutdownOptions = {}) {
|
||||
accepting = false;
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
if (shutdownOptions.force) {
|
||||
for (const { controller } of active.values()) controller.abort();
|
||||
}
|
||||
await Promise.allSettled([...active.values()].map(({ promise }) => promise));
|
||||
},
|
||||
size: () => jobs.length,
|
||||
get: (id) => jobs.find((job) => job.id === id),
|
||||
get: (id) => {
|
||||
const job = jobs.find((candidate) => candidate.id === id);
|
||||
return job ? { ...job } : undefined;
|
||||
},
|
||||
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);
|
||||
if (index >= 0) {
|
||||
jobs.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
const running = active.get(id);
|
||||
if (!running) return false;
|
||||
running.controller.abort();
|
||||
return true;
|
||||
},
|
||||
failed: () => [...deadLetters.values()].map((job) => ({ ...job })),
|
||||
async retry(id) {
|
||||
if (!accepting) throw new Error("WRN-QUEUE-CLOSED: queue is shutting down");
|
||||
const job = deadLetters.get(id);
|
||||
if (!job) return false;
|
||||
deadLetters.delete(id);
|
||||
jobs.push({ ...job, attempts: 0, runAt: now() });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
export { memoryQueueStore, createDurableQueue } from "./durable.ts";
|
||||
export type { QueueStore, DurableQueue, DurableQueueOptions } from "./durable.ts";
|
||||
export { redisQueueStore, postgresQueueStore, POSTGRES_QUEUE_SCHEMA } from "./stores.ts";
|
||||
export type { RedisQueueClient, SqlQueueClient } from "./stores.ts";
|
||||
export {
|
||||
createQueueScheduler,
|
||||
addBatch,
|
||||
queueDashboardSnapshot,
|
||||
renderQueueDashboard,
|
||||
runQueueDaemon,
|
||||
} from "./scheduler.ts";
|
||||
export type { ScheduledJob, QueueScheduler, QueueDashboardSnapshot } from "./scheduler.ts";
|
||||
export { createWorkflowEngine, defineDurableWorkflow, memoryWorkflowStore } from "./workflow.ts";
|
||||
export type {
|
||||
WorkflowDefinition,
|
||||
WorkflowEngine,
|
||||
WorkflowRunContext,
|
||||
WorkflowSnapshot,
|
||||
WorkflowStatus,
|
||||
WorkflowStore,
|
||||
} from "./workflow.ts";
|
||||
import { createExecutionContext, type ExecutionContext } from "@wrnexus/core";
|
||||
|
||||
Reference in New Issue
Block a user