Files
WRNexusJS/packages/queue/src/index.ts
T
ClintchizandClaude Opus 5 5112cc1a62
Quality / quality (ubuntu-latest) (push) Failing after 13m21s
Quality / quality (windows-latest) (push) Canceled after 0s
docs: measure the runtime and the generated client modules
Adds a per-subsystem measurement of reactive.js, made by minifying it
repeatedly with one subsystem removed rather than counting source bytes.

This corrects the earlier audit on both figures and on the conclusion drawn
from them. Component controllers are 23,722 bytes minified / 6,660 gzipped --
30.6% of transfer, not the "about 18%" previously claimed -- and splitting them
out saves 6.6 kB gzipped on a typical page, not "3-4 kB". Measured against the
example app, / and /login use none of the ten controllers and /layout uses one,
so most pages download and parse the lot for nothing.

The larger finding is that the runtime is not where the weight is. One page
parses 490,212 decoded bytes across 11 generated client modules while
transferring 21,026, and the largest module is 89.8% duplicated lines: the
state-restore prologue appears 162 times because client-codegen.ts inlines the
sync into every peer alias of every client function. Gzip hides it on the wire,
but parse cost follows decoded bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:02:36 +05:30

320 lines
11 KiB
TypeScript

/**
* @wrnexus/queue — a background job queue with delays, retries + backoff, and
* concurrent workers. The default store is in-process; a pluggable driver lets
* you back it with Redis/SQL for durability across restarts.
*
* const queue = createQueue();
* queue.process("email", async (job) => { await send(job.data); });
* await queue.add("email", { to: "a@b.com" }, { delayMs: 5000, maxAttempts: 3 });
* queue.start(); // begin polling; queue.stop() to halt
*
* Tests can drive it deterministically with `await queue.drain(now)`.
*/
export interface Job<T = unknown> {
id: string;
name: string;
data: T;
attempts: number;
maxAttempts: number;
runAt: number;
/** If set, re-enqueue this job this many ms after each successful run. */
repeat?: number;
priority: number;
idempotencyKey?: string;
createdAt: number;
}
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). */
delayMs?: number;
/** Max attempts before it's dead-lettered. Default from queue options. */
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 {
/** Default max attempts per job. Default 3. */
maxAttempts?: number;
/** Base retry backoff (ms); doubles per attempt. Default 1000. */
backoffMs?: number;
/** Poll interval when started (ms). Default 250. */
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;
/** 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 {
add<T>(name: string, data: T, options?: AddOptions): Promise<Job<T>>;
process<T>(name: string, handler: JobHandler<T>): void;
/** Run every job whose runAt ≤ now, once. Returns how many ran. */
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> {
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;
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)
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");
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);
if (!handler) return; // no worker registered yet — leave it queued
const idx = jobs.indexOf(job);
if (idx >= 0) jobs.splice(idx, 1); // claim it
job.attempts++;
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);
}
})();
active.set(job.id, { controller, promise: execution });
await execution;
}
const drain: Queue["drain"] = async (at) => {
if (draining) return 0;
draining = true;
try {
const cutoff = at ?? now();
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 {
draining = false;
}
};
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 &&
(!Number.isInteger(opts.maxAttempts) || opts.maxAttempts < 1)
)
throw new RangeError("job maxAttempts must be a positive integer");
if (opts.delayMs !== undefined && (!Number.isFinite(opts.delayMs) || opts.delayMs < 0))
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>;
}
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}`,
name,
data,
attempts: 0,
maxAttempts: opts.maxAttempts ?? defaultMax,
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>;
},
process(name, handler) {
handlers.set(name, handler as JobHandler);
},
drain,
start() {
if (!accepting) throw new Error("WRN-QUEUE-CLOSED: queue is shutting down");
if (timer) return;
timer = setInterval(() => void drain(), pollMs);
},
stop() {
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) => {
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) {
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";
export { subjectQueue } from "./subject.ts";
export type { SubjectJob, SubjectQueue } from "./subject.ts";
import { createExecutionContext, type ExecutionContext } from "@wrnexus/core";