Initial Commit
This commit is contained in:
63
packages/foundation/flow/queue/README.md
Normal file
63
packages/foundation/flow/queue/README.md
Normal 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 }
|
||||
```
|
||||
133
packages/foundation/flow/queue/package.json
Normal file
133
packages/foundation/flow/queue/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
11
packages/foundation/flow/queue/src/errors/index.ts
Normal file
11
packages/foundation/flow/queue/src/errors/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
QUEUE_CODES,
|
||||
QueueDeadLetterFailedError,
|
||||
QueueError,
|
||||
type QueueErrorCode,
|
||||
QueueFullError,
|
||||
QueueHandlerFailedError,
|
||||
QueueInvalidStateError,
|
||||
QueueJobNotFoundError,
|
||||
QueueWorkerAbortedError,
|
||||
} from "./queue-error.js";
|
||||
95
packages/foundation/flow/queue/src/errors/queue-error.md
Normal file
95
packages/foundation/flow/queue/src/errors/queue-error.md
Normal 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.
|
||||
155
packages/foundation/flow/queue/src/errors/queue-error.test.ts
Normal file
155
packages/foundation/flow/queue/src/errors/queue-error.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
packages/foundation/flow/queue/src/errors/queue-error.ts
Normal file
110
packages/foundation/flow/queue/src/errors/queue-error.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
12
packages/foundation/flow/queue/src/index.ts
Normal file
12
packages/foundation/flow/queue/src/index.ts
Normal 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";
|
||||
1
packages/foundation/flow/queue/src/job/index.ts
Normal file
1
packages/foundation/flow/queue/src/job/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Job } from "./job.js";
|
||||
111
packages/foundation/flow/queue/src/job/job.md
Normal file
111
packages/foundation/flow/queue/src/job/job.md
Normal 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.
|
||||
188
packages/foundation/flow/queue/src/job/job.test.ts
Normal file
188
packages/foundation/flow/queue/src/job/job.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
177
packages/foundation/flow/queue/src/job/job.ts
Normal file
177
packages/foundation/flow/queue/src/job/job.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
65
packages/foundation/flow/queue/src/queue/delayed-queue.md
Normal file
65
packages/foundation/flow/queue/src/queue/delayed-queue.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
34
packages/foundation/flow/queue/src/queue/delayed-queue.ts
Normal file
34
packages/foundation/flow/queue/src/queue/delayed-queue.ts
Normal 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);
|
||||
}
|
||||
7
packages/foundation/flow/queue/src/queue/index.ts
Normal file
7
packages/foundation/flow/queue/src/queue/index.ts
Normal 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";
|
||||
86
packages/foundation/flow/queue/src/queue/memory-queue.md
Normal file
86
packages/foundation/flow/queue/src/queue/memory-queue.md
Normal 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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
82
packages/foundation/flow/queue/src/queue/memory-queue.ts
Normal file
82
packages/foundation/flow/queue/src/queue/memory-queue.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
70
packages/foundation/flow/queue/src/queue/priority-queue.md
Normal file
70
packages/foundation/flow/queue/src/queue/priority-queue.md
Normal 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).
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
47
packages/foundation/flow/queue/src/queue/priority-queue.ts
Normal file
47
packages/foundation/flow/queue/src/queue/priority-queue.ts
Normal 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);
|
||||
}
|
||||
11
packages/foundation/flow/queue/src/types/index.ts
Normal file
11
packages/foundation/flow/queue/src/types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
type EnqueueOptions,
|
||||
EnqueueOptionsSchema,
|
||||
type JobState,
|
||||
JobStateSchema,
|
||||
type QueueLike,
|
||||
type RetryPolicy,
|
||||
RetryPolicySchema,
|
||||
type WorkerOptions,
|
||||
WorkerOptionsSchema,
|
||||
} from "./queue-types.js";
|
||||
81
packages/foundation/flow/queue/src/types/queue-types.md
Normal file
81
packages/foundation/flow/queue/src/types/queue-types.md
Normal 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`.
|
||||
99
packages/foundation/flow/queue/src/types/queue-types.test.ts
Normal file
99
packages/foundation/flow/queue/src/types/queue-types.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
81
packages/foundation/flow/queue/src/types/queue-types.ts
Normal file
81
packages/foundation/flow/queue/src/types/queue-types.ts
Normal 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;
|
||||
}
|
||||
6
packages/foundation/flow/queue/src/worker/index.ts
Normal file
6
packages/foundation/flow/queue/src/worker/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type JobHandler,
|
||||
QueueWorker,
|
||||
runWorker,
|
||||
type WorkerStats,
|
||||
} from "./queue-worker.js";
|
||||
100
packages/foundation/flow/queue/src/worker/queue-worker.md
Normal file
100
packages/foundation/flow/queue/src/worker/queue-worker.md
Normal 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.
|
||||
354
packages/foundation/flow/queue/src/worker/queue-worker.test.ts
Normal file
354
packages/foundation/flow/queue/src/worker/queue-worker.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
206
packages/foundation/flow/queue/src/worker/queue-worker.ts
Normal file
206
packages/foundation/flow/queue/src/worker/queue-worker.ts
Normal 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;
|
||||
}
|
||||
8
packages/foundation/flow/queue/tests/setup/polyfills.ts
Normal file
8
packages/foundation/flow/queue/tests/setup/polyfills.ts
Normal 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;
|
||||
}
|
||||
12
packages/foundation/flow/queue/tsconfig.lib.json
Normal file
12
packages/foundation/flow/queue/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
37
packages/foundation/flow/queue/vite.config.ts
Normal file
37
packages/foundation/flow/queue/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/foundation/flow/queue/vitest.config.ts
Normal file
26
packages/foundation/flow/queue/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user