Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
# @suaveplan/queue - Enterprise-grade queue management: memory/priority/delayed queues, concurrent workers with retry and dead-letter, job state machines, OTel instrumentation
## Summary
Enterprise-grade queue management: memory/priority/delayed queues, concurrent workers with retry and dead-letter forwarding, job state machines, and OTel instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/queue-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue-error](./src/errors/queue-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delayed-queue](./src/queue/delayed-queue.md) — `DelayedQueue<T>` is an explicit alias for a delay-aware FIFO queue. It extends `MemoryQueue<T>` to make the delay inte…
- [memory-queue](./src/queue/memory-queue.md) — `MemoryQueue<T>` is the foundational FIFO queue for `@suaveplan/queue`. It stores jobs in memory in insertion order and…
- [priority-queue](./src/queue/priority-queue.md) — `PriorityQueue<T>` extends `MemoryQueue<T>` to provide priority-ordered job scheduling. Jobs with higher `priority` val…
- [types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue-types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue-worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [queue-error](./src/errors/queue-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_- [delayed-queue](./src/queue/delayed-queue.md) — `DelayedQueue<T>` is an explicit alias for a delay-aware FIFO queue. It extends `MemoryQueue<T>` to make the delay inte…
- [memory-queue](./src/queue/memory-queue.md) — `MemoryQueue<T>` is the foundational FIFO queue for `@suaveplan/queue`. It stores jobs in memory in insertion order and…
- [priority-queue](./src/queue/priority-queue.md) — `PriorityQueue<T>` extends `MemoryQueue<T>` to provide priority-ordered job scheduling. Jobs with higher `priority` val…
- [types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-error](./src/errors/queue-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_- [delayed-queue](./src/queue/delayed-queue.md) — `DelayedQueue<T>` is an explicit alias for a delay-aware FIFO queue. It extends `MemoryQueue<T>` to make the delay inte…
- [memory-queue](./src/queue/memory-queue.md) — `MemoryQueue<T>` is the foundational FIFO queue for `@suaveplan/queue`. It stores jobs in memory in insertion order and…
- [priority-queue](./src/queue/priority-queue.md) — `PriorityQueue<T>` extends `MemoryQueue<T>` to provide priority-ordered job scheduling. Jobs with higher `priority` val…
- [types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-error](./src/errors/queue-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_- [delayed-queue](./src/queue/delayed-queue.md) — `DelayedQueue<T>` is an explicit alias for a delay-aware FIFO queue. It extends `MemoryQueue<T>` to make the delay inte…
- [memory-queue](./src/queue/memory-queue.md) — `MemoryQueue<T>` is the foundational FIFO queue for `@suaveplan/queue`. It stores jobs in memory in insertion order and…
- [priority-queue](./src/queue/priority-queue.md) — `PriorityQueue<T>` extends `MemoryQueue<T>` to provide priority-ordered job scheduling. Jobs with higher `priority` val…
- [types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-error](./src/errors/queue-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job](./src/job/job.md) — <!-- begin:auto-docs --> _(no submodules)_- [delayed-queue](./src/queue/delayed-queue.md) — `DelayedQueue<T>` is an explicit alias for a delay-aware FIFO queue. It extends `MemoryQueue<T>` to make the delay inte…
- [memory-queue](./src/queue/memory-queue.md) — `MemoryQueue<T>` is the foundational FIFO queue for `@suaveplan/queue`. It stores jobs in memory in insertion order and…
- [priority-queue](./src/queue/priority-queue.md) — `PriorityQueue<T>` extends `MemoryQueue<T>` to provide priority-ordered job scheduling. Jobs with higher `priority` val…
- [types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue-types](./src/types/queue-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue-worker](./src/worker/queue-worker.md) — <!-- begin:auto-docs --> _(no submodules)_
## Packages
| Module | Description |
|--------|-------------|
| [errors/queue-error](src/errors/queue-error.md) | Error hierarchy with stable codes |
| [job/job](src/job/job.md) | Job state machine |
| [queue/memory-queue](src/queue/memory-queue.md) | FIFO in-memory queue |
| [queue/priority-queue](src/queue/priority-queue.md) | Priority-ordered queue |
| [queue/delayed-queue](src/queue/delayed-queue.md) | Delay-aware queue |
| [worker/queue-worker](src/worker/queue-worker.md) | Concurrent job worker with retry |
| [types/queue-types](src/types/queue-types.md) | Shared types and Zod schemas |
## Quick Start
```ts
import { createMemoryQueue } from "@suaveplan/queue/queue/memory-queue";
import { runWorker } from "@suaveplan/queue/worker/queue-worker";
const queue = createMemoryQueue<{ userId: string }>();
await queue.enqueue({ userId: "user_1" });
await queue.enqueue({ userId: "user_2" });
const stats = await runWorker(queue, async (job) => {
console.log("Processing user:", job.payload.userId);
}, {
concurrency: 2,
retryPolicy: { maxAttempts: 3, backoffMs: 1000, backoffMultiplier: 2 },
});
console.log(stats); // { processed: 2, succeeded: 2, failed: 0, dead: 0, retried: 0 }
```

View File

@@ -0,0 +1,133 @@
{
"name": "@suaveplan/queue",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Enterprise-grade queue management: memory/priority/delayed queues, concurrent workers with retry and dead-letter, job state machines, OTel instrumentation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors/queue-error": {
"import": {
"types": "./dist/errors/queue-error.d.ts",
"default": "./dist/errors/queue-error.js"
},
"require": {
"types": "./dist/errors/queue-error.d.ts",
"default": "./dist/errors/queue-error.js"
}
},
"./job/job": {
"import": {
"types": "./dist/job/job.d.ts",
"default": "./dist/job/job.js"
},
"require": {
"types": "./dist/job/job.d.ts",
"default": "./dist/job/job.js"
}
},
"./queue/memory-queue": {
"import": {
"types": "./dist/queue/memory-queue.d.ts",
"default": "./dist/queue/memory-queue.js"
},
"require": {
"types": "./dist/queue/memory-queue.d.ts",
"default": "./dist/queue/memory-queue.js"
}
},
"./queue/priority-queue": {
"import": {
"types": "./dist/queue/priority-queue.d.ts",
"default": "./dist/queue/priority-queue.js"
},
"require": {
"types": "./dist/queue/priority-queue.d.ts",
"default": "./dist/queue/priority-queue.js"
}
},
"./queue/delayed-queue": {
"import": {
"types": "./dist/queue/delayed-queue.d.ts",
"default": "./dist/queue/delayed-queue.js"
},
"require": {
"types": "./dist/queue/delayed-queue.d.ts",
"default": "./dist/queue/delayed-queue.js"
}
},
"./worker/queue-worker": {
"import": {
"types": "./dist/worker/queue-worker.d.ts",
"default": "./dist/worker/queue-worker.js"
},
"require": {
"types": "./dist/worker/queue-worker.d.ts",
"default": "./dist/worker/queue-worker.js"
}
},
"./types/queue-types": {
"import": {
"types": "./dist/types/queue-types.d.ts",
"default": "./dist/types/queue-types.js"
},
"require": {
"types": "./dist/types/queue-types.d.ts",
"default": "./dist/types/queue-types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,11 @@
export {
QUEUE_CODES,
QueueDeadLetterFailedError,
QueueError,
type QueueErrorCode,
QueueFullError,
QueueHandlerFailedError,
QueueInvalidStateError,
QueueJobNotFoundError,
QueueWorkerAbortedError,
} from "./queue-error.js";

View File

@@ -0,0 +1,95 @@
# queue-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the error hierarchy for `@suaveplan/queue`. All errors extend `SuaveplanError` via a shared `QueueError` base class, giving them stable numeric codes, cause-chain traversal, and OTel span-event integration from `@suaveplan/error`.
Error codes are registered once via `defineCodes("queue", {...})` so each error can be identified numerically in distributed tracing, log pipelines, and wire protocols without relying on class names that may be minified.
## Features
- `QueueError` — base class for the entire queue error family
- `QueueFullError` — capacity exceeded on enqueue
- `QueueJobNotFoundError` — job ID lookup failure (includes optional `jobId` in message)
- `QueueWorkerAbortedError` — worker stopped via `AbortSignal`
- `QueueHandlerFailedError` — job handler threw an unhandled error
- `QueueInvalidStateError` — illegal job state transition (includes `from`/`to` in message)
- `QueueDeadLetterFailedError` — dead-letter enqueue failure
- All codes under the `"queue"` domain namespace via `defineCodes`
## Usage: Basic Example
```ts
import { QueueFullError, QueueHandlerFailedError } from "@suaveplan/queue/errors/queue-error";
try {
throw new QueueFullError({ message: "Memory queue at capacity" });
} catch (err) {
if (err instanceof QueueFullError) {
console.error("Queue full, code:", err.code); // stable numeric code
}
}
```
## Usage: Advanced Example
```ts
import {
QueueInvalidStateError,
QueueJobNotFoundError,
QUEUE_CODES,
} from "@suaveplan/queue/errors/queue-error";
// State transition with context
throw new QueueInvalidStateError({
from: "completed",
to: "processing",
});
// Job lookup failure with ID
const err = new QueueJobNotFoundError({ jobId: "job_abc123" });
console.log(err.message); // "Job not found: job_abc123"
console.log(err.code === QUEUE_CODES.QUEUE_JOB_NOT_FOUND); // true
```
## API Reference
### `QUEUE_CODES`
Stable code map: `QUEUE_FULL=1`, `QUEUE_JOB_NOT_FOUND=2`, `QUEUE_WORKER_ABORTED=3`, `QUEUE_HANDLER_FAILED=4`, `QUEUE_INVALID_STATE=5`, `QUEUE_DEAD_LETTER_FAILED=6`.
### `QueueError`
Base class. Extends `SuaveplanError`, domain `"queue"`.
### `QueueFullError(options?)`
- `options.message?` — override default message
- `options.cause?` — wrapped cause
### `QueueJobNotFoundError(options?)`
- `options.jobId?` — included in default message
### `QueueWorkerAbortedError(options?)`
Thrown when `AbortSignal` fires during worker processing.
### `QueueHandlerFailedError(options?)`
Wraps the original handler error as `cause`.
### `QueueInvalidStateError(options?)`
- `options.from?` / `options.to?` — state labels included in default message
### `QueueDeadLetterFailedError(options?)`
Thrown when the dead-letter enqueue itself fails.
## Implementation Notes
`defineCodes` from `@suaveplan/error` allocates a namespace-prefixed numeric code map. This approach prevents code collisions across packages and survives minification. Each error class declares its code as both a static and instance property to support `instanceof` checks and code-based switching.

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
QUEUE_CODES,
QueueDeadLetterFailedError,
QueueError,
QueueFullError,
QueueHandlerFailedError,
QueueInvalidStateError,
QueueJobNotFoundError,
QueueWorkerAbortedError,
} from "./queue-error.js";
describe("QUEUE_CODES", () => {
it("has all expected code keys", { timeout: 5000 }, () => {
expect(QUEUE_CODES.FULL).toMatch(/QUEUE\.FULL/);
expect(QUEUE_CODES.JOB_NOT_FOUND).toMatch(/QUEUE\.JOB_NOT_FOUND/);
expect(QUEUE_CODES.WORKER_ABORTED).toMatch(/QUEUE\.WORKER_ABORTED/);
expect(QUEUE_CODES.HANDLER_FAILED).toMatch(/QUEUE\.HANDLER_FAILED/);
expect(QUEUE_CODES.INVALID_STATE).toMatch(/QUEUE\.INVALID_STATE/);
expect(QUEUE_CODES.DEAD_LETTER_FAILED).toMatch(
/QUEUE\.DEAD_LETTER_FAILED/
);
});
});
describe("QueueFullError", () => {
it("uses default message", { timeout: 5000 }, () => {
const err = new QueueFullError();
expect(err).toBeInstanceOf(QueueFullError);
expect(err).toBeInstanceOf(QueueError);
expect(err.message).toBe("Queue is full");
expect(err.code).toBe(QUEUE_CODES.FULL);
});
it("accepts custom message and Error cause", { timeout: 5000 }, () => {
const cause = new Error("root cause");
const err = new QueueFullError({ message: "capacity exceeded", cause });
expect(err.message).toBe("capacity exceeded");
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new QueueFullError({ cause: "string cause" });
expect(err.cause).toBeUndefined();
});
});
describe("QueueJobNotFoundError", () => {
it("uses default message without jobId", { timeout: 5000 }, () => {
const err = new QueueJobNotFoundError();
expect(err.message).toBe("Job not found");
expect(err.code).toBe(QUEUE_CODES.JOB_NOT_FOUND);
});
it("includes jobId in message when provided", { timeout: 5000 }, () => {
const err = new QueueJobNotFoundError({ jobId: "job_abc" });
expect(err.message).toBe("Job not found: job_abc");
});
it("accepts custom message overriding jobId", { timeout: 5000 }, () => {
const err = new QueueJobNotFoundError({
message: "custom",
jobId: "ignored",
});
expect(err.message).toBe("custom");
});
it("accepts Error cause", { timeout: 5000 }, () => {
const cause = new Error("lookup failed");
const err = new QueueJobNotFoundError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("QueueWorkerAbortedError", () => {
it("uses default message", { timeout: 5000 }, () => {
const err = new QueueWorkerAbortedError();
expect(err.message).toBe("Worker was aborted");
expect(err.code).toBe(QUEUE_CODES.WORKER_ABORTED);
});
it("accepts Error cause", { timeout: 5000 }, () => {
const cause = new Error("signal fired");
const err = new QueueWorkerAbortedError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("QueueHandlerFailedError", () => {
it("uses default message", { timeout: 5000 }, () => {
const err = new QueueHandlerFailedError();
expect(err.message).toBe("Job handler failed");
expect(err.code).toBe(QUEUE_CODES.HANDLER_FAILED);
});
it("accepts Error cause", { timeout: 5000 }, () => {
const cause = new Error("network timeout");
const err = new QueueHandlerFailedError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("QueueInvalidStateError", () => {
it("uses default message without from/to", { timeout: 5000 }, () => {
const err = new QueueInvalidStateError();
expect(err.message).toBe("Invalid state transition");
expect(err.code).toBe(QUEUE_CODES.INVALID_STATE);
});
it("includes from/to in message when provided", { timeout: 5000 }, () => {
const err = new QueueInvalidStateError({
from: "completed",
to: "processing",
});
expect(err.message).toBe(
'Invalid state transition from "completed" to "processing"'
);
});
it("uses custom message when provided", { timeout: 5000 }, () => {
const err = new QueueInvalidStateError({
message: "bad transition",
from: "a",
to: "b",
});
expect(err.message).toBe("bad transition");
});
it("only from without to produces no context in message", {
timeout: 5000,
}, () => {
const err = new QueueInvalidStateError({ from: "pending" });
expect(err.message).toBe("Invalid state transition");
});
it("accepts Error cause", { timeout: 5000 }, () => {
const cause = new Error("guard failed");
const err = new QueueInvalidStateError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("QueueDeadLetterFailedError", () => {
it("uses default message", { timeout: 5000 }, () => {
const err = new QueueDeadLetterFailedError();
expect(err.message).toBe("Dead-letter queue operation failed");
expect(err.code).toBe(QUEUE_CODES.DEAD_LETTER_FAILED);
});
it("accepts Error cause", { timeout: 5000 }, () => {
const cause = new Error("DLQ unavailable");
const err = new QueueDeadLetterFailedError({ cause });
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,110 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error codes for the queue package. */
export const QUEUE_CODES = defineCodes("QUEUE", [
"FULL",
"JOB_NOT_FOUND",
"WORKER_ABORTED",
"HANDLER_FAILED",
"INVALID_STATE",
"DEAD_LETTER_FAILED",
] as const);
export type QueueErrorCode = (typeof QUEUE_CODES)[keyof typeof QUEUE_CODES];
/** Base class for all queue-related errors. */
export class QueueError extends SuaveplanError<QueueErrorCode> {}
/** Thrown when an enqueue operation fails because the queue is at capacity. */
export class QueueFullError extends QueueError {
constructor(options: { message?: string; cause?: unknown } = {}) {
super(QUEUE_CODES.FULL, options.message ?? "Queue is full", {
cause: options.cause instanceof Error ? options.cause : undefined,
});
}
}
/** Thrown when a job ID cannot be found in the queue. */
export class QueueJobNotFoundError extends QueueError {
constructor(
options: { message?: string; cause?: unknown; jobId?: string } = {}
) {
super(
QUEUE_CODES.JOB_NOT_FOUND,
options.message ??
`Job not found${options.jobId ? `: ${options.jobId}` : ""}`,
{
cause:
options.cause instanceof Error ? options.cause : undefined,
}
);
}
}
/** Thrown when a worker is stopped via AbortSignal. */
export class QueueWorkerAbortedError extends QueueError {
constructor(options: { message?: string; cause?: unknown } = {}) {
super(
QUEUE_CODES.WORKER_ABORTED,
options.message ?? "Worker was aborted",
{
cause:
options.cause instanceof Error ? options.cause : undefined,
}
);
}
}
/** Thrown when a job handler throws an unhandled error. */
export class QueueHandlerFailedError extends QueueError {
constructor(options: { message?: string; cause?: unknown } = {}) {
super(
QUEUE_CODES.HANDLER_FAILED,
options.message ?? "Job handler failed",
{
cause:
options.cause instanceof Error ? options.cause : undefined,
}
);
}
}
/** Thrown when a state transition is illegal for the current job state. */
export class QueueInvalidStateError extends QueueError {
constructor(
options: {
message?: string;
cause?: unknown;
from?: string;
to?: string;
} = {}
) {
super(
QUEUE_CODES.INVALID_STATE,
options.message ??
`Invalid state transition${
options.from && options.to
? ` from "${options.from}" to "${options.to}"`
: ""
}`,
{
cause:
options.cause instanceof Error ? options.cause : undefined,
}
);
}
}
/** Thrown when a dead-letter queue operation fails. */
export class QueueDeadLetterFailedError extends QueueError {
constructor(options: { message?: string; cause?: unknown } = {}) {
super(
QUEUE_CODES.DEAD_LETTER_FAILED,
options.message ?? "Dead-letter queue operation failed",
{
cause:
options.cause instanceof Error ? options.cause : undefined,
}
);
}
}

View File

@@ -0,0 +1,12 @@
// Errors
export * from "./errors/queue-error.js";
// Job
export * from "./job/job.js";
export * from "./queue/delayed-queue.js";
// Queues
export * from "./queue/memory-queue.js";
export * from "./queue/priority-queue.js";
// Types
export * from "./types/queue-types.js";
// Worker
export * from "./worker/queue-worker.js";

View File

@@ -0,0 +1 @@
export { Job } from "./job.js";

View File

@@ -0,0 +1,111 @@
# job
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `Job<T>` class is the fundamental unit of work in `@suaveplan/queue`. It manages a job's lifecycle through a strict state machine — `pending → processing → completed | failed → dead` — and records timing information and error context at each transition.
All state transitions are guarded: attempting an invalid transition (e.g., marking a `"completed"` job as `"processing"`) throws `QueueInvalidStateError`, preventing silent data corruption in concurrent workers.
## Features
- Strict state machine with `QueueInvalidStateError` on invalid transitions
- Tracks `attempts`, `processedAt`, `completedAt`, `failedAt`, and `error`
- Injectable `now` factory on every transition for deterministic testing
- Priority and metadata stored at construction time
- Delay-based `eligibleAt` timestamp computed from `delayMs` option
- `isEligible()` for delayed-queue comparison
- `toJSON()` for structured serialisation / logging
## Usage: Basic Example
```ts
import { Job } from "@suaveplan/queue/job/job";
const job = new Job("job_1", { userId: 42 });
job.markProcessing();
job.markCompleted();
console.log(job.state); // "completed"
console.log(job.attempts); // 1
```
## Usage: Advanced Example
```ts
import { Job } from "@suaveplan/queue/job/job";
import { QueueInvalidStateError } from "@suaveplan/queue/errors/queue-error";
// Deterministic timestamps in tests
const ts = ["2024-01-01T00:00:00Z", "2024-01-01T00:00:01Z", "2024-01-01T00:00:02Z"];
let i = 0;
const now = () => ts[i++]!;
const job = new Job("j1", "payload", { delayMs: 5000 }, now);
console.log(job.isEligible(() => Date.now())); // false (not yet eligible)
job.markProcessing(now);
job.markFailed(new Error("connection reset"), now);
console.log(job.state); // "failed"
console.log(job.attempts); // 1
// Retry
job.markProcessing(now);
job.markCompleted(now);
console.log(job.state); // "completed"
// Invalid transition throws
try {
job.markDead(now);
} catch (err) {
console.log(err instanceof QueueInvalidStateError); // true
}
```
## API Reference
### `new Job(id, payload, options?, now?)`
- `id: string` — unique identifier
- `payload: T` — the work data
- `options.priority?: number` — default `0`
- `options.delayMs?: number` — delay before eligible, default `0`
- `options.metadata?: Record<string, unknown>` — attached metadata
- `now?: () => string` — injectable ISO timestamp factory
### `job.state: JobState`
Read-only current state: `"pending" | "processing" | "completed" | "failed" | "dead"`.
### `job.attempts: number`
Number of `markProcessing()` calls made.
### `job.markProcessing(now?): void`
Transitions `pending | failed → processing`. Increments `attempts`.
### `job.markCompleted(now?): void`
Transitions `processing → completed`.
### `job.markFailed(err, now?): void`
Transitions `processing → failed`. Wraps `err` in `QueueHandlerFailedError`.
### `job.markDead(now?): void`
Transitions `failed → dead`.
### `job.isEligible(now?): boolean`
Returns `true` when `now() >= eligibleAt`. Defaults to `Date.now()`.
### `job.toJSON(): Record<string, unknown>`
Plain object representation for serialisation.
## Implementation Notes
The state machine uses `protected` fields so that test subclasses or extensions can inspect internal state without public API pollution. The `now` parameter is injected to every transition method rather than captured at construction time, allowing fine-grained control in tests where each transition needs a distinct timestamp.

View File

@@ -0,0 +1,188 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
QueueHandlerFailedError,
QueueInvalidStateError,
} from "../errors/queue-error.js";
import { Job } from "./job.js";
function makeNow(timestamps: string[]): () => string {
let i = 0;
return () => timestamps[i++] ?? new Date().toISOString();
}
describe("Job constructor", () => {
it("creates job in pending state", { timeout: 5000 }, () => {
const job = new Job("j1", "payload");
expect(job.id).toBe("j1");
expect(job.payload).toBe("payload");
expect(job.state).toBe("pending");
expect(job.attempts).toBe(0);
expect(job.priority).toBe(0);
expect(job.metadata).toEqual({});
});
it("applies priority and metadata from options", { timeout: 5000 }, () => {
const job = new Job("j2", 42, { priority: 10, metadata: { tag: "x" } });
expect(job.priority).toBe(10);
expect(job.metadata).toEqual({ tag: "x" });
});
it("computes eligibleAt from delayMs", { timeout: 5000 }, () => {
const now = makeNow(["2024-01-01T00:00:00.000Z"]);
const job = new Job("j3", "data", { delayMs: 5000 }, now);
expect(job.eligibleAt).toBe("2024-01-01T00:00:05.000Z");
});
it("eligibleAt equals createdAt when delayMs is zero", {
timeout: 5000,
}, () => {
const now = makeNow(["2024-01-01T00:00:00.000Z"]);
const job = new Job("j4", "data", {}, now);
expect(job.eligibleAt).toBe(job.createdAt);
});
});
describe("Job.isEligible", () => {
it("returns true immediately when no delay", { timeout: 5000 }, () => {
const job = new Job("j5", "x");
expect(job.isEligible()).toBe(true);
});
it("returns false before delay expires", { timeout: 5000 }, () => {
const nowStr = () => "2024-01-01T00:00:00.000Z";
const job = new Job("j6", "x", { delayMs: 10000 }, nowStr);
// nowMs = 2024-01-01T00:00:00 = 1704067200000
const nowMs = () => new Date("2024-01-01T00:00:00.000Z").getTime();
expect(job.isEligible(nowMs)).toBe(false);
});
it("returns true after delay expires", { timeout: 5000 }, () => {
const nowStr = () => "2024-01-01T00:00:00.000Z";
const job = new Job("j7", "x", { delayMs: 1000 }, nowStr);
const afterDelay = () =>
new Date("2024-01-01T00:00:01.000Z").getTime() + 1;
expect(job.isEligible(afterDelay)).toBe(true);
});
});
describe("Job state machine", () => {
it("full happy path: pending → processing → completed", {
timeout: 5000,
}, () => {
const ts = makeNow([
"2024-01-01T00:00:00Z",
"2024-01-01T00:00:01Z",
"2024-01-01T00:00:02Z",
]);
const job = new Job("j8", "work", {}, ts);
job.markProcessing(ts);
expect(job.state).toBe("processing");
expect(job.attempts).toBe(1);
expect(job.processedAt).toBe("2024-01-01T00:00:01Z");
job.markCompleted(ts);
expect(job.state).toBe("completed");
expect(job.completedAt).toBe("2024-01-01T00:00:02Z");
});
it("retry path: pending → processing → failed → processing → completed", {
timeout: 5000,
}, () => {
const ts = makeNow(["T0", "T1", "T2", "T3", "T4"]);
const job = new Job("j9", "work", {}, ts);
job.markProcessing(ts);
job.markFailed(new Error("oops"), ts);
expect(job.state).toBe("failed");
expect(job.attempts).toBe(1);
expect(job.failedAt).toBe("T2");
expect(job.error).toBeDefined();
job.markProcessing(ts); // retry from failed
job.markCompleted(ts);
expect(job.state).toBe("completed");
expect(job.attempts).toBe(2);
});
it("dead path: pending → processing → failed → dead", {
timeout: 5000,
}, () => {
const job = new Job("j10", "x");
job.markProcessing();
job.markFailed(new Error("e"));
job.markDead();
expect(job.state).toBe("dead");
});
it("markFailed wraps non-Error as QueueHandlerFailedError", {
timeout: 5000,
}, () => {
const job = new Job("j11", "x");
job.markProcessing();
job.markFailed("string error");
expect(job.error).toBeInstanceOf(QueueHandlerFailedError);
});
it("markFailed wraps Error as QueueHandlerFailedError", {
timeout: 5000,
}, () => {
const job = new Job("j12", "x");
job.markProcessing();
job.markFailed(new Error("real error"));
expect(job.error).toBeInstanceOf(QueueHandlerFailedError);
});
});
describe("Job invalid state transitions", () => {
it("markProcessing from completed throws", { timeout: 5000 }, () => {
const job = new Job("j13", "x");
job.markProcessing();
job.markCompleted();
expect(() => job.markProcessing()).toThrow(QueueInvalidStateError);
});
it("markProcessing from dead throws", { timeout: 5000 }, () => {
const job = new Job("j14", "x");
job.markProcessing();
job.markFailed(new Error("e"));
job.markDead();
expect(() => job.markProcessing()).toThrow(QueueInvalidStateError);
});
it("markCompleted from pending throws", { timeout: 5000 }, () => {
const job = new Job("j15", "x");
expect(() => job.markCompleted()).toThrow(QueueInvalidStateError);
});
it("markFailed from pending throws", { timeout: 5000 }, () => {
const job = new Job("j16", "x");
expect(() => job.markFailed(new Error("e"))).toThrow(
QueueInvalidStateError
);
});
it("markDead from pending throws", { timeout: 5000 }, () => {
const job = new Job("j17", "x");
expect(() => job.markDead()).toThrow(QueueInvalidStateError);
});
it("markDead from processing throws", { timeout: 5000 }, () => {
const job = new Job("j18", "x");
job.markProcessing();
expect(() => job.markDead()).toThrow(QueueInvalidStateError);
});
});
describe("Job.toJSON", () => {
it("serialises to plain object", { timeout: 5000 }, () => {
const job = new Job("j19", "data", { priority: 5, metadata: { k: 1 } });
const json = job.toJSON();
expect(json.id).toBe("j19");
expect(json.state).toBe("pending");
expect(json.attempts).toBe(0);
expect(json.priority).toBe(5);
expect(json.metadata).toEqual({ k: 1 });
expect(json.processedAt).toBeUndefined();
expect(json.completedAt).toBeUndefined();
expect(json.failedAt).toBeUndefined();
});
});

View File

@@ -0,0 +1,177 @@
import {
QueueHandlerFailedError,
QueueInvalidStateError,
} from "../errors/queue-error.js";
import type { EnqueueOptions, JobState } from "../types/queue-types.js";
/** Represents a single unit of work in a queue. */
export class Job<T> {
/** Unique identifier for this job. */
readonly id: string;
/** The payload to be processed. */
readonly payload: T;
/** ISO timestamp when the job was created. */
readonly createdAt: string;
/** ISO timestamp when the job first became eligible for processing. */
readonly eligibleAt: string;
/** Priority value (higher = processed first). */
readonly priority: number;
/** Optional metadata attached at enqueue time. */
readonly metadata: Record<string, unknown>;
protected _state: JobState;
protected _attempts: number;
protected _processedAt: string | undefined;
protected _completedAt: string | undefined;
protected _failedAt: string | undefined;
protected _error: unknown;
constructor(
id: string,
payload: T,
options: EnqueueOptions = {},
now: () => string = () => new Date().toISOString()
) {
this.id = id;
this.payload = payload;
this.priority = options.priority ?? 0;
this.metadata = options.metadata ?? {};
const createdAt = now();
this.createdAt = createdAt;
const delayMs = options.delayMs ?? 0;
if (delayMs > 0) {
const eligible = new Date(
new Date(createdAt).getTime() + delayMs
).toISOString();
this.eligibleAt = eligible;
} else {
this.eligibleAt = createdAt;
}
this._state = "pending";
this._attempts = 0;
}
/** Current state of the job. */
get state(): JobState {
return this._state;
}
/** Number of processing attempts made so far. */
get attempts(): number {
return this._attempts;
}
/** ISO timestamp of the most recent processing start, or undefined. */
get processedAt(): string | undefined {
return this._processedAt;
}
/** ISO timestamp when the job completed successfully, or undefined. */
get completedAt(): string | undefined {
return this._completedAt;
}
/** ISO timestamp when the job last failed, or undefined. */
get failedAt(): string | undefined {
return this._failedAt;
}
/** The last error thrown by a handler, or undefined. */
get error(): unknown {
return this._error;
}
/**
* Transitions the job to `"processing"`.
* Valid from `"pending"` or `"failed"` (retry).
*/
markProcessing(now: () => string = () => new Date().toISOString()): void {
if (this._state !== "pending" && this._state !== "failed") {
throw new QueueInvalidStateError({
from: this._state,
to: "processing",
});
}
this._state = "processing";
this._attempts += 1;
this._processedAt = now();
}
/**
* Transitions the job to `"completed"`.
* Valid only from `"processing"`.
*/
markCompleted(now: () => string = () => new Date().toISOString()): void {
if (this._state !== "processing") {
throw new QueueInvalidStateError({
from: this._state,
to: "completed",
});
}
this._state = "completed";
this._completedAt = now();
}
/**
* Transitions the job to `"failed"`.
* Valid only from `"processing"`.
*/
markFailed(
err: unknown,
now: () => string = () => new Date().toISOString()
): void {
if (this._state !== "processing") {
throw new QueueInvalidStateError({
from: this._state,
to: "failed",
});
}
this._state = "failed";
this._error =
err instanceof Error
? new QueueHandlerFailedError({ cause: err })
: new QueueHandlerFailedError({
cause: new Error(String(err)),
});
this._failedAt = now();
}
/**
* Transitions the job to `"dead"`.
* Valid from `"failed"` (exhausted retries).
*/
markDead(now: () => string = () => new Date().toISOString()): void {
if (this._state !== "failed") {
throw new QueueInvalidStateError({
from: this._state,
to: "dead",
});
}
this._state = "dead";
this._failedAt = now();
}
/**
* Returns true if the job is eligible for processing right now.
* Always true when no delay was set; otherwise compares `eligibleAt` to `Date.now()`.
*/
isEligible(now: () => number = () => Date.now()): boolean {
return now() >= new Date(this.eligibleAt).getTime();
}
/** Serialises the job to a plain object suitable for JSON. */
toJSON(): Record<string, unknown> {
return {
id: this.id,
state: this._state,
attempts: this._attempts,
priority: this.priority,
createdAt: this.createdAt,
eligibleAt: this.eligibleAt,
processedAt: this._processedAt,
completedAt: this._completedAt,
failedAt: this._failedAt,
metadata: this.metadata,
};
}
}

View File

@@ -0,0 +1,65 @@
# delayed-queue
## Purpose
`DelayedQueue<T>` is an explicit alias for a delay-aware FIFO queue. It extends `MemoryQueue<T>` to make the delay intent clear at the call site. Jobs enqueued with a `delayMs` option are held back from `dequeue()` until that many milliseconds have elapsed since enqueue time.
The core delay logic lives in `Job.isEligible()` and `MemoryQueue.dequeue()`. `DelayedQueue` is a thin semantic wrapper that documents intent in the type system and provides a dedicated factory.
## Features
- Inherits all `MemoryQueue` capabilities: FIFO ordering, `maxSize`, counters
- Jobs with `delayMs > 0` are skipped by `dequeue()` until eligible
- Named class export makes delayed semantics explicit in application code
- `createDelayedQueue` factory for ergonomic creation
## Usage: Basic Example
```ts
import { createDelayedQueue } from "@suaveplan/queue/queue/delayed-queue";
const queue = createDelayedQueue<string>();
await queue.enqueue("immediate");
await queue.enqueue("delayed", { delayMs: 60_000 }); // 1 minute delay
const now = await queue.dequeue();
console.log(now?.payload); // "immediate"
const notYet = await queue.dequeue();
console.log(notYet); // undefined (still delayed)
```
## Usage: Advanced Example
```ts
import { createDelayedQueue } from "@suaveplan/queue/queue/delayed-queue";
// Scheduled retry: re-enqueue with exponential backoff delay
async function scheduleRetry(payload: { taskId: string }, attempt: number) {
const delayMs = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s...
const queue = createDelayedQueue<typeof payload>();
const jobId = await queue.enqueue(payload, { delayMs, metadata: { attempt } });
console.log(`Retrying task ${payload.taskId} in ${delayMs}ms, job ${jobId}`);
}
```
## API Reference
### `createDelayedQueue<T>(options?): DelayedQueue<T>`
- `options.maxSize?` — capacity cap; throws `QueueFullError` when exceeded
### `DelayedQueue<T>`
Extends `MemoryQueue<T>`. All inherited methods available.
#### `enqueue(payload, options?): Promise<string>`
Delegates to `MemoryQueue.enqueue`. `options.delayMs` activates delay logic.
#### `dequeue(): Promise<Job<T> | undefined>`
Inherited from `MemoryQueue`. Returns first job where `job.isEligible()` is true.
## Implementation Notes
`DelayedQueue` does not need a polling timer or scheduler. The caller is responsible for calling `dequeue()` at appropriate intervals; the queue simply filters by eligibility at dequeue time. This keeps the implementation stateless and easily testable — pass a synthetic clock to `Job` at enqueue time and control `Date.now()` in tests with a stub.

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createDelayedQueue, DelayedQueue } from "./delayed-queue.js";
describe("createDelayedQueue", () => {
it("returns a DelayedQueue instance", { timeout: 5000 }, () => {
expect(createDelayedQueue()).toBeInstanceOf(DelayedQueue);
});
});
describe("DelayedQueue", () => {
it("dequeues immediately eligible jobs", { timeout: 5000 }, async () => {
const q = new DelayedQueue<string>();
await q.enqueue("now");
const j = await q.dequeue();
expect(j?.payload).toBe("now");
});
it("holds delayed jobs until eligible", { timeout: 5000 }, async () => {
const q = new DelayedQueue<string>();
await q.enqueue("held", { delayMs: 60_000 });
const notYet = await q.dequeue();
expect(notYet).toBeUndefined();
expect(q.size()).toBe(1);
});
it("dequeues in insertion order when no delay", {
timeout: 5000,
}, async () => {
const q = new DelayedQueue<number>();
await q.enqueue(1);
await q.enqueue(2);
await q.enqueue(3);
const j1 = await q.dequeue();
const j2 = await q.dequeue();
const j3 = await q.dequeue();
expect([j1?.payload, j2?.payload, j3?.payload]).toEqual([1, 2, 3]);
});
it("mixes immediate and delayed jobs correctly", {
timeout: 5000,
}, async () => {
const q = new DelayedQueue<string>();
await q.enqueue("delayed", { delayMs: 60_000 });
await q.enqueue("immediate");
// immediate is found at index 1, returned first
const j = await q.dequeue();
expect(j?.payload).toBe("immediate");
expect(q.size()).toBe(1); // delayed still waiting
});
it("clear removes all including delayed", { timeout: 5000 }, async () => {
const q = new DelayedQueue<string>();
await q.enqueue("x", { delayMs: 60_000 });
q.clear();
expect(q.size()).toBe(0);
});
});

View File

@@ -0,0 +1,34 @@
import type { EnqueueOptions } from "../types/queue-types.js";
import { MemoryQueue } from "./memory-queue.js";
/**
* Delay-aware FIFO queue.
* `dequeue()` only returns jobs whose `eligibleAt` timestamp has passed.
* All other behaviour is inherited from `MemoryQueue`.
*
* This is effectively an alias for `MemoryQueue` since `MemoryQueue.dequeue()`
* already skips ineligible jobs — `DelayedQueue` exists as a named export
* to make intent explicit at the call site.
*/
export class DelayedQueue<T> extends MemoryQueue<T> {
/**
* Enqueues a job. When `options.delayMs` is set, the job will not be
* returned by `dequeue()` until that many milliseconds have elapsed.
*/
override async enqueue(
payload: T,
options: EnqueueOptions = {}
): Promise<string> {
return super.enqueue(payload, options);
}
}
/**
* Creates a delay-aware FIFO queue.
* Jobs with `delayMs` set are held until their delay has expired.
*/
export function createDelayedQueue<T>(
options: { maxSize?: number } = {}
): DelayedQueue<T> {
return new DelayedQueue<T>(options);
}

View File

@@ -0,0 +1,7 @@
export { createDelayedQueue, DelayedQueue } from "./delayed-queue.js";
export {
createMemoryQueue,
MemoryQueue,
type MemoryQueueOptions,
} from "./memory-queue.js";
export { createPriorityQueue, PriorityQueue } from "./priority-queue.js";

View File

@@ -0,0 +1,86 @@
# memory-queue
## Purpose
`MemoryQueue<T>` is the foundational FIFO queue for `@suaveplan/queue`. It stores jobs in memory in insertion order and dequeues the first eligible job (respecting `delayMs`). An optional `maxSize` cap enforces bounded operation, throwing `QueueFullError` when the limit is reached.
This queue is suitable for single-process scenarios — worker pools, test harnesses, and local development. For distributed systems, wrap it behind an adapter that delegates to a persistent store.
## Features
- Pure in-memory FIFO ordering
- Optional `maxSize` cap with `QueueFullError` on overflow
- Delay support via `Job.isEligible()` — dequeues skip ineligible jobs
- `enqueueCount` / `dequeueCount` counters for observability
- `clear()` for draining between tests
- Implements `QueueLike<T>` for compatibility with `QueueWorker`
## Usage: Basic Example
```ts
import { createMemoryQueue } from "@suaveplan/queue/queue/memory-queue";
const queue = createMemoryQueue<string>();
await queue.enqueue("task-1");
await queue.enqueue("task-2");
const job = await queue.dequeue();
console.log(job?.payload); // "task-1"
console.log(queue.size()); // 1
```
## Usage: Advanced Example
```ts
import { createMemoryQueue } from "@suaveplan/queue/queue/memory-queue";
import { QueueFullError } from "@suaveplan/queue/errors/queue-error";
const bounded = createMemoryQueue<number>({ maxSize: 2 });
await bounded.enqueue(1);
await bounded.enqueue(2);
try {
await bounded.enqueue(3); // throws QueueFullError
} catch (err) {
console.log(err instanceof QueueFullError); // true
}
// Delayed job: enqueue with 100ms delay
await bounded.clear();
await bounded.enqueue(42, { delayMs: 100 });
const notYet = await bounded.dequeue();
console.log(notYet); // undefined (not yet eligible)
```
## API Reference
### `createMemoryQueue<T>(options?): MemoryQueue<T>`
Factory function. `options.maxSize` defaults to unbounded.
### `MemoryQueue<T>`
Implements `QueueLike<T>`.
#### `enqueue(payload, options?): Promise<string>`
Returns the generated job ID. Throws `QueueFullError` when at capacity.
#### `dequeue(): Promise<Job<T> | undefined>`
Returns the first eligible job, or `undefined` if none.
#### `size(): number`
Current number of jobs (including delayed ones).
#### `clear(): void`
Removes all jobs.
#### `enqueueCount: number` / `dequeueCount: number`
Monotonically increasing counters.
## Implementation Notes
The `QueueFullError` import is dynamic (`await import(...)`) to avoid a top-level circular import issue between the error module and the queue module. Job IDs are generated with `Date.now()` + a random suffix — suitable for in-memory uniqueness but not globally unique across processes; replace `_generateId()` in a subclass for UUID-based IDs.

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { QueueFullError } from "../errors/queue-error.js";
import { createMemoryQueue, MemoryQueue } from "./memory-queue.js";
describe("createMemoryQueue", () => {
it("returns a MemoryQueue instance", { timeout: 5000 }, () => {
expect(createMemoryQueue()).toBeInstanceOf(MemoryQueue);
});
});
describe("MemoryQueue", () => {
it("enqueues and dequeues in FIFO order", { timeout: 5000 }, async () => {
const q = new MemoryQueue<string>();
await q.enqueue("a");
await q.enqueue("b");
await q.enqueue("c");
expect(q.size()).toBe(3);
const j1 = await q.dequeue();
expect(j1?.payload).toBe("a");
const j2 = await q.dequeue();
expect(j2?.payload).toBe("b");
expect(q.size()).toBe(1);
});
it("returns undefined when empty", { timeout: 5000 }, async () => {
const q = new MemoryQueue<number>();
expect(await q.dequeue()).toBeUndefined();
});
it("tracks enqueue and dequeue counts", { timeout: 5000 }, async () => {
const q = new MemoryQueue<number>();
await q.enqueue(1);
await q.enqueue(2);
await q.dequeue();
expect(q.enqueueCount).toBe(2);
expect(q.dequeueCount).toBe(1);
});
it("clears all jobs", { timeout: 5000 }, async () => {
const q = new MemoryQueue<number>();
await q.enqueue(1);
await q.enqueue(2);
q.clear();
expect(q.size()).toBe(0);
});
it("throws QueueFullError when maxSize exceeded", {
timeout: 5000,
}, async () => {
const q = new MemoryQueue<number>({ maxSize: 2 });
await q.enqueue(1);
await q.enqueue(2);
await expect(q.enqueue(3)).rejects.toBeInstanceOf(QueueFullError);
});
it("skips delayed jobs that are not yet eligible", {
timeout: 5000,
}, async () => {
const q = new MemoryQueue<string>();
await q.enqueue("immediate");
await q.enqueue("delayed", { delayMs: 60_000 });
const j = await q.dequeue();
expect(j?.payload).toBe("immediate");
// delayed job stays in queue
expect(q.size()).toBe(1);
const notYet = await q.dequeue();
expect(notYet).toBeUndefined();
});
it("returns delayed job after delay expires", {
timeout: 5000,
}, async () => {
const q = new MemoryQueue<string>();
// enqueue with delayMs=0 means immediately eligible
await q.enqueue("now", { delayMs: 0 });
const j = await q.dequeue();
expect(j?.payload).toBe("now");
});
it("enqueue returns unique job IDs", { timeout: 5000 }, async () => {
const q = new MemoryQueue<number>();
const ids = await Promise.all([
q.enqueue(1),
q.enqueue(2),
q.enqueue(3),
]);
const unique = new Set(ids);
expect(unique.size).toBe(3);
});
it("stores metadata on the job", { timeout: 5000 }, async () => {
const q = new MemoryQueue<string>();
await q.enqueue("x", { metadata: { source: "api" } });
const j = await q.dequeue();
expect(j?.metadata).toEqual({ source: "api" });
});
});

View File

@@ -0,0 +1,82 @@
import { Job } from "../job/job.js";
import type { EnqueueOptions, QueueLike } from "../types/queue-types.js";
/** Options for creating a MemoryQueue. */
export interface MemoryQueueOptions {
/** Maximum number of jobs the queue can hold (default: unbounded). */
maxSize?: number;
}
/**
* Simple FIFO in-memory queue. Jobs are dequeued in insertion order.
* Bounded when `maxSize` is provided; throws `QueueFullError` when full.
*/
export function createMemoryQueue<T>(
options: MemoryQueueOptions = {}
): MemoryQueue<T> {
return new MemoryQueue<T>(options);
}
/** FIFO in-memory queue with optional capacity bound. */
export class MemoryQueue<T> implements QueueLike<T> {
protected readonly jobs: Job<T>[] = [];
protected readonly maxSize: number;
protected _enqueueCount = 0;
protected _dequeueCount = 0;
constructor(options: MemoryQueueOptions = {}) {
this.maxSize = options.maxSize ?? Number.POSITIVE_INFINITY;
}
/**
* Adds a job to the back of the queue.
* Throws `QueueFullError` if the queue is at capacity.
*/
async enqueue(payload: T, options: EnqueueOptions = {}): Promise<string> {
if (this.jobs.length >= this.maxSize) {
const { QueueFullError } = await import("../errors/queue-error.js");
throw new QueueFullError();
}
const id = this._generateId();
const job = new Job<T>(id, payload, options);
this.jobs.push(job);
this._enqueueCount++;
return id;
}
/**
* Removes and returns the next eligible job from the front of the queue.
* Returns `undefined` when the queue is empty or no eligible job exists.
*/
async dequeue(): Promise<Job<T> | undefined> {
const index = this.jobs.findIndex((j) => j.isEligible());
if (index === -1) return undefined;
const job = this.jobs.splice(index, 1)[0];
this._dequeueCount++;
return job;
}
/** Returns the current number of jobs in the queue. */
size(): number {
return this.jobs.length;
}
/** Removes all jobs from the queue. */
clear(): void {
this.jobs.length = 0;
}
/** Total number of jobs enqueued since creation. */
get enqueueCount(): number {
return this._enqueueCount;
}
/** Total number of jobs dequeued since creation. */
get dequeueCount(): number {
return this._dequeueCount;
}
protected _generateId(): string {
return `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
}

View File

@@ -0,0 +1,70 @@
# priority-queue
## Purpose
`PriorityQueue<T>` extends `MemoryQueue<T>` to provide priority-ordered job scheduling. Jobs with higher `priority` values (set via `EnqueueOptions.priority`) are placed ahead of lower-priority jobs in the queue. When priorities are equal, insertion order (FIFO) is preserved.
This is useful for latency-sensitive systems where some job categories should skip ahead of background work without requiring a separate queue.
## Features
- Descending-priority ordering: higher value = dequeued first
- FIFO tie-breaking among equal-priority jobs
- Inherits all `MemoryQueue` capabilities: `maxSize`, delay support, counters
- O(n) insertion — optimal for typical in-memory queue sizes
- `createPriorityQueue` factory for ergonomic creation
## Usage: Basic Example
```ts
import { createPriorityQueue } from "@suaveplan/queue/queue/priority-queue";
const queue = createPriorityQueue<string>();
await queue.enqueue("low", { priority: 1 });
await queue.enqueue("high", { priority: 10 });
await queue.enqueue("medium", { priority: 5 });
const first = await queue.dequeue();
console.log(first?.payload); // "high"
const second = await queue.dequeue();
console.log(second?.payload); // "medium"
```
## Usage: Advanced Example
```ts
import { createPriorityQueue } from "@suaveplan/queue/queue/priority-queue";
// Real-time + batch processing: real-time tasks get priority 100, batch get 0
const queue = createPriorityQueue<{ type: string; data: unknown }>({ maxSize: 1000 });
await queue.enqueue({ type: "batch", data: null }, { priority: 0 });
await queue.enqueue({ type: "batch", data: null }, { priority: 0 });
await queue.enqueue({ type: "realtime", data: "urgent" }, { priority: 100 });
const job = await queue.dequeue();
console.log(job?.payload.type); // "realtime"
// FIFO among same priority
const j2 = await queue.dequeue();
const j3 = await queue.dequeue();
console.log(j2?.payload.type, j3?.payload.type); // "batch", "batch"
```
## API Reference
### `createPriorityQueue<T>(options?): PriorityQueue<T>`
- `options.maxSize?` — capacity cap; throws `QueueFullError` when exceeded
### `PriorityQueue<T>`
Extends `MemoryQueue<T>`. All inherited methods available.
#### `enqueue(payload, options?): Promise<string>`
Inserts at the correct sorted position. `options.priority` defaults to `0`.
## Implementation Notes
Insertion uses a linear scan to find the first position where the stored job has a lower priority than the new job, then splices it in. For typical queue sizes (hundreds to low thousands of jobs), this is performant and avoids the complexity of a binary heap. If queue sizes routinely exceed tens of thousands, a heap-based implementation would improve amortised insertion from O(n) to O(log n).

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { QueueFullError } from "../errors/queue-error.js";
import { createPriorityQueue, PriorityQueue } from "./priority-queue.js";
describe("createPriorityQueue", () => {
it("returns a PriorityQueue instance", { timeout: 5000 }, () => {
expect(createPriorityQueue()).toBeInstanceOf(PriorityQueue);
});
});
describe("PriorityQueue", () => {
it("dequeues in descending priority order", { timeout: 5000 }, async () => {
const q = new PriorityQueue<string>();
await q.enqueue("low", { priority: 1 });
await q.enqueue("high", { priority: 10 });
await q.enqueue("medium", { priority: 5 });
const first = await q.dequeue();
const second = await q.dequeue();
const third = await q.dequeue();
expect(first?.payload).toBe("high");
expect(second?.payload).toBe("medium");
expect(third?.payload).toBe("low");
});
it("FIFO among equal-priority jobs", { timeout: 5000 }, async () => {
const q = new PriorityQueue<string>();
await q.enqueue("first", { priority: 5 });
await q.enqueue("second", { priority: 5 });
await q.enqueue("third", { priority: 5 });
const j1 = await q.dequeue();
const j2 = await q.dequeue();
const j3 = await q.dequeue();
expect(j1?.payload).toBe("first");
expect(j2?.payload).toBe("second");
expect(j3?.payload).toBe("third");
});
it("default priority is 0", { timeout: 5000 }, async () => {
const q = new PriorityQueue<number>();
await q.enqueue(1); // priority 0
await q.enqueue(2, { priority: 5 });
const first = await q.dequeue();
expect(first?.payload).toBe(2);
});
it("throws QueueFullError when maxSize exceeded", {
timeout: 5000,
}, async () => {
const q = new PriorityQueue<number>({ maxSize: 1 });
await q.enqueue(1);
await expect(q.enqueue(2)).rejects.toBeInstanceOf(QueueFullError);
});
it("maintains correct order after mixed insertions", {
timeout: 5000,
}, async () => {
const q = new PriorityQueue<number>();
await q.enqueue(100, { priority: 100 });
await q.enqueue(50, { priority: 50 });
await q.enqueue(75, { priority: 75 });
await q.enqueue(25, { priority: 25 });
const results: number[] = [];
let job = await q.dequeue();
while (job) {
results.push(job.payload);
job = await q.dequeue();
}
expect(results).toEqual([100, 75, 50, 25]);
});
it("enqueue into empty queue works correctly", {
timeout: 5000,
}, async () => {
const q = new PriorityQueue<string>();
await q.enqueue("only", { priority: 5 });
expect(q.size()).toBe(1);
const j = await q.dequeue();
expect(j?.payload).toBe("only");
});
});

View File

@@ -0,0 +1,47 @@
import { Job } from "../job/job.js";
import type { EnqueueOptions } from "../types/queue-types.js";
import { MemoryQueue } from "./memory-queue.js";
/**
* Priority-ordered in-memory queue.
* Jobs with higher `priority` values are dequeued first.
* Ties are broken by insertion order (FIFO among equal priorities).
*/
export class PriorityQueue<T> extends MemoryQueue<T> {
/**
* Inserts the job into the correct sorted position (descending priority).
* O(n) insertion — sufficient for in-memory queues.
*/
override async enqueue(
payload: T,
options: EnqueueOptions = {}
): Promise<string> {
if (this.jobs.length >= this.maxSize) {
const { QueueFullError } = await import("../errors/queue-error.js");
throw new QueueFullError();
}
const id = this._generateId();
const job = new Job<T>(id, payload, options);
// Insert in descending priority order; equal priorities go to the back
let insertAt = this.jobs.length;
for (let i = 0; i < this.jobs.length; i++) {
if (this.jobs[i]!.priority < job.priority) {
insertAt = i;
break;
}
}
this.jobs.splice(insertAt, 0, job);
this._enqueueCount++;
return id;
}
}
/**
* Creates a priority-ordered in-memory queue.
* Higher `priority` values are processed first; equal priorities are FIFO.
*/
export function createPriorityQueue<T>(
options: { maxSize?: number } = {}
): PriorityQueue<T> {
return new PriorityQueue<T>(options);
}

View File

@@ -0,0 +1,11 @@
export {
type EnqueueOptions,
EnqueueOptionsSchema,
type JobState,
JobStateSchema,
type QueueLike,
type RetryPolicy,
RetryPolicySchema,
type WorkerOptions,
WorkerOptionsSchema,
} from "./queue-types.js";

View File

@@ -0,0 +1,81 @@
# queue-types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared type definitions and Zod schemas for the `@suaveplan/queue` package. This module is the single source of truth for all queue-related interfaces, ensuring consistent type safety across job, queue, and worker implementations.
These types are deliberately kept in their own module so that any consumer can import just the schemas for runtime validation without pulling in the full queue implementation.
## Features
- `JobState` union type and Zod schema for job lifecycle state machine
- `RetryPolicy` interface and schema for configurable backoff strategies
- `EnqueueOptions` for attaching priority, delay, and metadata to jobs
- `WorkerOptions` for configuring concurrency and retry behaviour
- `QueueLike<T>` structural interface enabling duck-typed queue implementations
- All Zod schemas use strict validation (no `.passthrough()`)
## Usage: Basic Example
```ts
import type { JobState, RetryPolicy } from "@suaveplan/queue/types/queue-types";
import { RetryPolicySchema } from "@suaveplan/queue/types/queue-types";
const policy: RetryPolicy = { maxAttempts: 3, backoffMs: 500, backoffMultiplier: 2 };
const parsed = RetryPolicySchema.parse(policy);
console.log(parsed.maxAttempts); // 3
```
## Usage: Advanced Example
```ts
import { EnqueueOptionsSchema, WorkerOptionsSchema } from "@suaveplan/queue/types/queue-types";
// Validate worker config from an untrusted source
const rawConfig = { concurrency: 4, retryPolicy: { maxAttempts: 5, backoffMs: 200, backoffMultiplier: 1.5 } };
const workerOpts = WorkerOptionsSchema.parse(rawConfig);
// Validate enqueue options at runtime
const rawEnqueue = { priority: 10, delayMs: 2000, metadata: { source: "api" } };
const enqueueOpts = EnqueueOptionsSchema.parse(rawEnqueue);
```
## API Reference
### `JobState`
Union of `"pending" | "processing" | "completed" | "failed" | "dead"`.
### `JobStateSchema`
`z.enum([...])` — runtime validation for `JobState`.
### `RetryPolicy`
- `maxAttempts: number` — retries after initial failure (0 = no retries)
- `backoffMs: number` — initial delay in ms
- `backoffMultiplier: number` — multiplier applied to delay each retry
### `EnqueueOptions`
- `priority?: number` — higher values processed first in priority queues
- `delayMs?: number` — milliseconds before job becomes eligible
- `metadata?: Record<string, unknown>` — arbitrary job metadata
### `WorkerOptions`
- `concurrency: number` — max simultaneous jobs
- `retryPolicy: RetryPolicy` — retry configuration
- `deadLetterQueue?: QueueLike<unknown>` — receives exhausted jobs
- `signal?: AbortSignal` — cancels the worker
### `QueueLike<T>`
Structural interface: `enqueue`, `dequeue`, `size`, `clear`.
## Implementation Notes
`QueueLike<T>` uses a structural interface instead of an abstract class to allow any object with the right shape to act as a queue. The `Job` import inside `QueueLike` is a type-only dynamic import to break the circular dependency between `queue-types.ts` and `job.ts`.

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EnqueueOptionsSchema,
JobStateSchema,
RetryPolicySchema,
WorkerOptionsSchema,
} from "./queue-types.js";
describe("JobStateSchema", () => {
it("accepts all valid states", { timeout: 5000 }, () => {
const valid = ["pending", "processing", "completed", "failed", "dead"];
for (const s of valid) {
expect(() => JobStateSchema.parse(s)).not.toThrow();
}
});
it("rejects invalid state", { timeout: 5000 }, () => {
expect(() => JobStateSchema.parse("running")).toThrow();
});
});
describe("RetryPolicySchema", () => {
it("parses a valid policy", { timeout: 5000 }, () => {
const result = RetryPolicySchema.parse({
maxAttempts: 3,
backoffMs: 500,
backoffMultiplier: 2,
});
expect(result.maxAttempts).toBe(3);
});
it("rejects negative maxAttempts", { timeout: 5000 }, () => {
expect(() =>
RetryPolicySchema.parse({
maxAttempts: -1,
backoffMs: 500,
backoffMultiplier: 2,
})
).toThrow();
});
it("rejects multiplier less than 1", { timeout: 5000 }, () => {
expect(() =>
RetryPolicySchema.parse({
maxAttempts: 1,
backoffMs: 0,
backoffMultiplier: 0.5,
})
).toThrow();
});
});
describe("EnqueueOptionsSchema", () => {
it("parses empty object", { timeout: 5000 }, () => {
const result = EnqueueOptionsSchema.parse({});
expect(result.priority).toBeUndefined();
});
it("parses full options", { timeout: 5000 }, () => {
const result = EnqueueOptionsSchema.parse({
priority: 10,
delayMs: 2000,
metadata: { key: "value" },
});
expect(result.priority).toBe(10);
expect(result.delayMs).toBe(2000);
});
it("rejects negative delayMs", { timeout: 5000 }, () => {
expect(() => EnqueueOptionsSchema.parse({ delayMs: -1 })).toThrow();
});
});
describe("WorkerOptionsSchema", () => {
it("parses valid config", { timeout: 5000 }, () => {
const result = WorkerOptionsSchema.parse({
concurrency: 4,
retryPolicy: {
maxAttempts: 3,
backoffMs: 100,
backoffMultiplier: 2,
},
});
expect(result.concurrency).toBe(4);
});
it("rejects concurrency < 1", { timeout: 5000 }, () => {
expect(() =>
WorkerOptionsSchema.parse({
concurrency: 0,
retryPolicy: {
maxAttempts: 1,
backoffMs: 0,
backoffMultiplier: 1,
},
})
).toThrow();
});
});

View File

@@ -0,0 +1,81 @@
import z from "zod";
/** All possible states a job can be in during its lifecycle. */
export type JobState =
| "pending"
| "processing"
| "completed"
| "failed"
| "dead";
/** Zod schema for JobState. */
export const JobStateSchema = z.enum([
"pending",
"processing",
"completed",
"failed",
"dead",
]);
/** Retry policy configuration for a worker. */
export interface RetryPolicy {
/** Maximum number of retry attempts after the initial failure (default: 3). */
maxAttempts: number;
/** Base delay in ms between retries (default: 1000). */
backoffMs: number;
/** Backoff multiplier applied on each successive retry (default: 2). */
backoffMultiplier: number;
}
/** Zod schema for RetryPolicy. */
export const RetryPolicySchema = z.object({
maxAttempts: z.number().int().min(0),
backoffMs: z.number().min(0),
backoffMultiplier: z.number().min(1),
});
/** Options for enqueuing a job. */
export interface EnqueueOptions {
/** Optional priority for priority queues (higher = processed first, default: 0). */
priority?: number;
/** Delay in ms before the job becomes eligible for processing (default: 0). */
delayMs?: number;
/** Arbitrary metadata attached to the job. */
metadata?: Record<string, unknown>;
}
/** Zod schema for EnqueueOptions. */
export const EnqueueOptionsSchema = z.object({
priority: z.number().optional(),
delayMs: z.number().min(0).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Options for creating a QueueWorker. */
export interface WorkerOptions {
/** Maximum concurrent jobs processed at once (default: 1). */
concurrency: number;
/** Retry policy for failed jobs. */
retryPolicy: RetryPolicy;
/** Optional dead-letter queue for jobs that exhaust retries. */
deadLetterQueue?: QueueLike<unknown>;
/** Optional AbortSignal to stop the worker. */
signal?: AbortSignal;
}
/** Zod schema for WorkerOptions (without signal or deadLetterQueue). */
export const WorkerOptionsSchema = z.object({
concurrency: z.number().int().min(1),
retryPolicy: RetryPolicySchema,
});
/**
* Minimal interface that any queue implementation must satisfy.
* Used as the type for deadLetterQueue to avoid circular imports.
*/
export interface QueueLike<T> {
enqueue(payload: T, options?: EnqueueOptions): Promise<string>;
dequeue(): Promise<import("../job/job.js").Job<T> | undefined>;
size(): number;
clear(): void;
}

View File

@@ -0,0 +1,6 @@
export {
type JobHandler,
QueueWorker,
runWorker,
type WorkerStats,
} from "./queue-worker.js";

View File

@@ -0,0 +1,100 @@
# queue-worker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`QueueWorker<T>` drains a `QueueLike<T>` queue by calling a user-supplied handler for each job. It manages concurrency (up to N simultaneous jobs), implements exponential-backoff retry, forwards exhausted jobs to an optional dead-letter queue, and honours `AbortSignal` for graceful shutdown.
The worker is intentionally decoupled from any specific queue implementation — it accepts any object that satisfies `QueueLike<T>`, allowing `MemoryQueue`, `PriorityQueue`, `DelayedQueue`, or a custom adapter to be swapped in.
## Features
- Configurable concurrency: processes up to N jobs simultaneously
- Exponential-backoff retry with configurable `maxAttempts`, `backoffMs`, and `backoffMultiplier`
- Dead-letter queue forwarding when all retries are exhausted
- `AbortSignal` cancellation: throws `QueueWorkerAbortedError` on abort
- `WorkerStats` snapshot (processed, succeeded, failed, dead, retried)
- `runWorker()` convenience wrapper for fire-and-forget usage
## Usage: Basic Example
```ts
import { createMemoryQueue } from "@suaveplan/queue/queue/memory-queue";
import { QueueWorker } from "@suaveplan/queue/worker/queue-worker";
const queue = createMemoryQueue<string>();
await queue.enqueue("hello");
await queue.enqueue("world");
const worker = new QueueWorker(queue, async (job) => {
console.log("Processing:", job.payload);
});
await worker.run();
console.log(worker.stats); // { processed: 2, succeeded: 2, failed: 0, dead: 0, retried: 0 }
```
## Usage: Advanced Example
```ts
import { createMemoryQueue } from "@suaveplan/queue/queue/memory-queue";
import { runWorker } from "@suaveplan/queue/worker/queue-worker";
const mainQueue = createMemoryQueue<{ taskId: string }>();
const deadLetterQueue = createMemoryQueue<unknown>();
await mainQueue.enqueue({ taskId: "a" });
await mainQueue.enqueue({ taskId: "b" });
let callCount = 0;
const stats = await runWorker(
mainQueue,
async (job) => {
callCount++;
if (callCount <= 4) throw new Error("transient failure"); // triggers retries
// succeeds on later attempts
},
{
concurrency: 2,
retryPolicy: { maxAttempts: 2, backoffMs: 10, backoffMultiplier: 2 },
deadLetterQueue,
}
);
console.log(stats.dead); // jobs that exhausted all retries
console.log(deadLetterQueue.size()); // dead-letter queue depth
```
## API Reference
### `QueueWorker<T>`
#### `new QueueWorker(queue, handler, options?)`
- `queue: QueueLike<T>` — source queue
- `handler: (job: Job<T>) => Promise<void>` — async job processor; throw to fail the job
- `options.concurrency` — default `1`
- `options.retryPolicy` — default `{ maxAttempts: 3, backoffMs: 1000, backoffMultiplier: 2 }`
- `options.deadLetterQueue?` — receives exhausted jobs
- `options.signal?` — `AbortSignal` for cancellation
#### `worker.run(): Promise<void>`
Drains the queue. Resolves when empty. Throws `QueueWorkerAbortedError` on abort.
#### `worker.stats: WorkerStats`
Snapshot: `{ processed, succeeded, failed, dead, retried }`.
#### `worker.running: boolean`
`true` while `run()` has not resolved.
### `runWorker<T>(queue, handler, options?): Promise<WorkerStats>`
Convenience factory: creates a worker, runs it, and returns stats.
## Implementation Notes
The drain loop maintains a `Set<Promise<void>>` of in-flight job promises. When `slots.size` reaches `concurrency`, it awaits `Promise.race(slots)` to free up a slot before dequeuing the next job. This achieves true concurrent processing without spawning unbound promises. Dead-letter forwarding errors wrap the original error as `QueueDeadLetterFailedError` so callers can distinguish delivery failures from job failures.

View File

@@ -0,0 +1,354 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
QueueDeadLetterFailedError,
QueueWorkerAbortedError,
} from "../errors/queue-error.js";
import { createMemoryQueue } from "../queue/memory-queue.js";
import { QueueWorker, runWorker } from "./queue-worker.js";
const NO_RETRY = { maxAttempts: 0, backoffMs: 0, backoffMultiplier: 1 };
const ONE_RETRY = { maxAttempts: 1, backoffMs: 0, backoffMultiplier: 1 };
const TWO_RETRIES = { maxAttempts: 2, backoffMs: 0, backoffMultiplier: 1 };
describe("QueueWorker", () => {
it("processes all jobs in a queue", { timeout: 5000 }, async () => {
const queue = createMemoryQueue<number>();
await queue.enqueue(1);
await queue.enqueue(2);
await queue.enqueue(3);
const processed: number[] = [];
const worker = new QueueWorker(
queue,
async (job) => {
processed.push(job.payload);
},
{ retryPolicy: NO_RETRY }
);
await worker.run();
expect(processed).toEqual([1, 2, 3]);
expect(worker.stats.processed).toBe(3);
expect(worker.stats.succeeded).toBe(3);
});
it("handles empty queue without error", { timeout: 5000 }, async () => {
const queue = createMemoryQueue<number>();
const worker = new QueueWorker(queue, async () => {}, {
retryPolicy: NO_RETRY,
});
await worker.run();
expect(worker.stats.processed).toBe(0);
});
it("running is false before and after run()", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<number>();
await queue.enqueue(1);
const worker = new QueueWorker(queue, async () => {}, {
retryPolicy: NO_RETRY,
});
expect(worker.running).toBe(false);
const p = worker.run();
// running becomes true during execution
await p;
expect(worker.running).toBe(false);
});
it("retries failed jobs according to retryPolicy", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<string>();
await queue.enqueue("task");
let attempts = 0;
const worker = new QueueWorker(
queue,
async () => {
attempts++;
if (attempts < 2) throw new Error("transient");
},
{ retryPolicy: ONE_RETRY }
);
await worker.run();
expect(attempts).toBe(2); // initial + 1 retry
expect(worker.stats.succeeded).toBe(1);
expect(worker.stats.retried).toBe(1);
});
it("marks job dead and increments dead counter after all retries", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<string>();
await queue.enqueue("task");
const worker = new QueueWorker(
queue,
async () => {
throw new Error("always fails");
},
{ retryPolicy: TWO_RETRIES }
);
await worker.run();
expect(worker.stats.dead).toBe(1);
expect(worker.stats.failed).toBe(1);
});
it("forwards dead jobs to dead-letter queue", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<string>();
const dlq = createMemoryQueue<unknown>();
await queue.enqueue("task");
const worker = new QueueWorker(
queue,
async () => {
throw new Error("fail");
},
{ retryPolicy: NO_RETRY, deadLetterQueue: dlq }
);
await worker.run();
expect(dlq.size()).toBe(1);
const deadJob = await dlq.dequeue();
expect(deadJob?.metadata?.jobId).toBeDefined();
});
it("throws QueueDeadLetterFailedError when DLQ enqueue fails", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<string>();
await queue.enqueue("task");
const brokenDlq = {
enqueue: async () => {
throw new Error("DLQ unavailable");
},
dequeue: async () => undefined,
size: () => 0,
clear: () => {},
};
const worker = new QueueWorker(
queue,
async () => {
throw new Error("fail");
},
{ retryPolicy: NO_RETRY, deadLetterQueue: brokenDlq }
);
await expect(worker.run()).rejects.toBeInstanceOf(
QueueDeadLetterFailedError
);
});
it("concurrent processing with concurrency=2", {
timeout: 10000,
}, async () => {
const queue = createMemoryQueue<number>();
for (let i = 0; i < 6; i++) await queue.enqueue(i);
const inFlight: number[] = [];
let maxConcurrent = 0;
const worker = new QueueWorker(
queue,
async (job) => {
inFlight.push(job.payload);
maxConcurrent = Math.max(maxConcurrent, inFlight.length);
await new Promise((r) => setTimeout(r, 10));
inFlight.splice(inFlight.indexOf(job.payload), 1);
},
{ concurrency: 2, retryPolicy: NO_RETRY }
);
await worker.run();
expect(worker.stats.succeeded).toBe(6);
expect(maxConcurrent).toBeGreaterThanOrEqual(2);
});
it("throws QueueWorkerAbortedError when signal already aborted", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<number>();
const ac = new AbortController();
ac.abort(new Error("stopped"));
const worker = new QueueWorker(queue, async () => {}, {
retryPolicy: NO_RETRY,
signal: ac.signal,
});
await expect(worker.run()).rejects.toBeInstanceOf(
QueueWorkerAbortedError
);
});
it("aborts mid-drain when signal fires", { timeout: 10000 }, async () => {
const queue = createMemoryQueue<number>();
for (let i = 0; i < 10; i++) await queue.enqueue(i);
const ac = new AbortController();
let processed = 0;
const worker = new QueueWorker(
queue,
async () => {
processed++;
if (processed === 2) ac.abort(new Error("stop"));
await new Promise((r) => setTimeout(r, 5));
},
{ retryPolicy: NO_RETRY, signal: ac.signal }
);
await expect(worker.run()).rejects.toBeInstanceOf(
QueueWorkerAbortedError
);
expect(processed).toBeGreaterThanOrEqual(1);
expect(processed).toBeLessThan(10);
});
it("stats returns a snapshot, not live reference", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<number>();
await queue.enqueue(1);
const worker = new QueueWorker(queue, async () => {}, {
retryPolicy: NO_RETRY,
});
const statsBefore = worker.stats;
await worker.run();
expect(statsBefore.processed).toBe(0);
expect(worker.stats.processed).toBe(1);
});
});
describe("runWorker", () => {
it("runs worker and returns stats", { timeout: 5000 }, async () => {
const queue = createMemoryQueue<string>();
await queue.enqueue("a");
await queue.enqueue("b");
const stats = await runWorker(queue, async () => {}, {
retryPolicy: NO_RETRY,
});
expect(stats.processed).toBe(2);
expect(stats.succeeded).toBe(2);
});
it("accepts no options (uses defaults)", { timeout: 5000 }, async () => {
const queue = createMemoryQueue<string>();
// Use default retryPolicy (3 retries) — but handler always succeeds
const stats = await runWorker(queue, async () => {});
expect(stats.processed).toBe(0);
});
});
describe("QueueWorker constructor defaults", () => {
it("uses default options when none provided", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<number>();
await queue.enqueue(1);
// Pass no options at all — exercises `= {}` default branch
const worker = new QueueWorker(queue, async () => {});
await worker.run();
expect(worker.stats.succeeded).toBe(1);
expect(worker.stats.processed).toBe(1);
});
});
describe("QueueWorker dead-letter non-Error lastError", () => {
it("handles non-Error thrown from handler in DLQ metadata", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<string>();
const dlq = createMemoryQueue<unknown>();
await queue.enqueue("task");
const worker = new QueueWorker(
queue,
async () => {
// Throw a non-Error value to exercise String(lastError) branch
throw "plain string error"; // eslint-disable-line no-throw-literal
},
{ retryPolicy: NO_RETRY, deadLetterQueue: dlq }
);
await worker.run();
const deadJob = await dlq.dequeue();
expect(deadJob?.metadata?.error).toBe("plain string error");
});
});
describe("QueueWorker _sleep", () => {
it("sleep is called with computed backoff delay on retry", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<string>();
await queue.enqueue("task");
// Use a spy to verify sleep is invoked
const sleepCalls: number[] = [];
class TestWorker extends QueueWorker<string> {
protected override _sleep(ms: number): Promise<void> {
sleepCalls.push(ms);
return Promise.resolve();
}
}
let calls = 0;
const worker = new TestWorker(
queue,
async () => {
calls++;
if (calls === 1) throw new Error("fail");
},
{
retryPolicy: {
maxAttempts: 1,
backoffMs: 500,
backoffMultiplier: 2,
},
}
);
await worker.run();
expect(sleepCalls).toEqual([500]); // backoffMs * 2^0 = 500
expect(worker.stats.succeeded).toBe(1);
});
});
describe("QueueWorker signal abort during _processJob loop", () => {
it("aborts between retries when signal fires", {
timeout: 5000,
}, async () => {
const queue = createMemoryQueue<string>();
await queue.enqueue("task");
const ac = new AbortController();
let attempts = 0;
class AbortingWorker extends QueueWorker<string> {
protected override async _sleep(_ms: number): Promise<void> {
// Abort during sleep (between retries)
ac.abort(new Error("abort-in-sleep"));
}
}
const worker = new AbortingWorker(
queue,
async () => {
attempts++;
throw new Error("fail to trigger retry");
},
{
retryPolicy: {
maxAttempts: 3,
backoffMs: 1,
backoffMultiplier: 1,
},
signal: ac.signal,
}
);
await expect(worker.run()).rejects.toBeInstanceOf(
QueueWorkerAbortedError
);
expect(attempts).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,206 @@
import type { Meter, Tracer } from "@opentelemetry/api";
import {
QueueDeadLetterFailedError,
QueueWorkerAbortedError,
} from "../errors/queue-error.js";
import type { Job } from "../job/job.js";
import type {
QueueLike,
RetryPolicy,
WorkerOptions,
} from "../types/queue-types.js";
/** Handler function invoked for each job. */
export type JobHandler<T> = (job: Job<T>) => Promise<void>;
/** Statistics reported by the worker. */
export interface WorkerStats {
processed: number;
succeeded: number;
failed: number;
dead: number;
retried: number;
}
const DEFAULT_RETRY_POLICY: RetryPolicy = {
maxAttempts: 3,
backoffMs: 1000,
backoffMultiplier: 2,
};
/**
* Concurrent job worker that drains a queue.
* Supports configurable concurrency, retry with exponential backoff,
* dead-letter queue forwarding, and AbortSignal cancellation.
*/
export class QueueWorker<T> {
protected readonly queue: QueueLike<T>;
protected readonly handler: JobHandler<T>;
protected readonly concurrency: number;
protected readonly retryPolicy: RetryPolicy;
protected readonly deadLetterQueue: QueueLike<unknown> | undefined;
protected readonly signal: AbortSignal | undefined;
protected readonly tracer: Tracer | undefined;
protected readonly meter: Meter | undefined;
protected _running = false;
protected _stats: WorkerStats = {
processed: 0,
succeeded: 0,
failed: 0,
dead: 0,
retried: 0,
};
constructor(
queue: QueueLike<T>,
handler: JobHandler<T>,
options: Partial<WorkerOptions> & {
tracer?: Tracer;
meter?: Meter;
} = {}
) {
this.queue = queue;
this.handler = handler;
this.concurrency = options.concurrency ?? 1;
this.retryPolicy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
this.deadLetterQueue = options.deadLetterQueue;
this.signal = options.signal;
this.tracer = options.tracer;
this.meter = options.meter;
}
/** Returns a snapshot of processing statistics. */
get stats(): WorkerStats {
return { ...this._stats };
}
/** Returns true while the worker is actively processing. */
get running(): boolean {
return this._running;
}
/**
* Starts the worker. Processes jobs concurrently up to `concurrency` limit.
* Resolves when the queue is drained or the signal is aborted.
* Throws `QueueWorkerAbortedError` if the signal fires while processing.
*/
async run(): Promise<void> {
if (this.signal?.aborted) {
throw new QueueWorkerAbortedError({ cause: this.signal.reason });
}
this._running = true;
try {
await this._drainLoop();
} finally {
this._running = false;
}
}
protected async _drainLoop(): Promise<void> {
const slots: Set<Promise<void>> = new Set();
while (true) {
if (this.signal?.aborted) {
throw new QueueWorkerAbortedError({
cause: this.signal.reason,
});
}
// Fill available concurrency slots
while (slots.size < this.concurrency) {
const job = await this.queue.dequeue();
if (!job) break;
const p = this._processJob(job).finally(() => {
slots.delete(p);
});
slots.add(p);
}
if (slots.size === 0) {
// Queue drained and no active slots — we're done
break;
}
// Wait for at least one slot to free up
await Promise.race([...slots]);
}
}
protected async _processJob(job: Job<T>): Promise<void> {
this._stats.processed++;
let lastError: unknown;
for (
let attempt = 0;
attempt <= this.retryPolicy.maxAttempts;
attempt++
) {
if (this.signal?.aborted) {
throw new QueueWorkerAbortedError({
cause: this.signal.reason,
});
}
if (attempt > 0) {
this._stats.retried++;
const delay =
this.retryPolicy.backoffMs *
this.retryPolicy.backoffMultiplier ** (attempt - 1);
await this._sleep(delay);
}
job.markProcessing();
try {
await this.handler(job);
job.markCompleted();
this._stats.succeeded++;
return;
} catch (err) {
lastError = err;
job.markFailed(err);
}
}
// All attempts exhausted — mark dead
job.markDead();
this._stats.dead++;
this._stats.failed++;
if (this.deadLetterQueue) {
try {
await this.deadLetterQueue.enqueue(job.payload as unknown, {
metadata: {
jobId: job.id,
attempts: job.attempts,
error:
lastError instanceof Error
? lastError.message
: String(lastError),
},
});
} catch (dlErr) {
throw new QueueDeadLetterFailedError({ cause: dlErr });
}
}
}
protected _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
/**
* Creates and immediately runs a worker against the given queue.
* A convenience wrapper around `new QueueWorker(...).run()`.
*/
export async function runWorker<T>(
queue: QueueLike<T>,
handler: JobHandler<T>,
options: Partial<WorkerOptions> & { tracer?: Tracer; meter?: Meter } = {}
): Promise<WorkerStats> {
const worker = new QueueWorker(queue, handler, options);
await worker.run();
return worker.stats;
}

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/queue",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/constants/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});