Initial Commit

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

View File

@@ -0,0 +1,68 @@
# @suaveplan/queue-distribution-worker-thread - Web-Worker / Node worker_threads transport plugin for @suaveplan/queue-distribution: caller-injected WorkerFactory bridges to the platform-native worker, message correlation, Zod-validated reply envelope, bounded outstanding-request map.
## Summary
Web-Worker / Node `worker_threads` transport plugin for `@suaveplan/queue-distribution`. Ships `WorkerThreadCommunicator` (a `ThreadWorkerLike`-based message bridge with Zod-validated envelopes, bounded outstanding-request map, AbortSignal cancellation, structured timeouts) and `WorkerThreadWorker` (an `AbstractWorker` subclass that drives the communicator and reacts to crash / exit events).
The plugin lives separately from the core distribution package so HTTP-only and in-process-only consumers don't carry worker-thread bridge code they never use.
<!-- begin:auto-docs -->
## Documentation tree
- [worker-thread](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/queue-distribution-worker-thread
```
## Quick start
```ts
import {
WorkerManager,
WorkerPool,
} from "@suaveplan/queue-distribution";
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
const pool = new WorkerPool();
pool.addWorker(
new WorkerThreadWorker(
() => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }),
{ id: "hot-1", capacity: 4 },
),
);
await pool.startAllWorkers();
const manager = new WorkerManager(pool, { autoHealthCheck: false });
const result = await manager.assignTask(task, input);
```
## Modules
- [Errors](./src/errors/queue-distribution-worker-thread-error.md) — `QueueDistributionWorkerThreadError` + 4 stable `WT.*` codes.
- [WorkerThreadCommunicator](./src/communicator/worker-thread/worker-thread-communicator.md) — message bridge with Zod envelope.
- [WorkerThreadWorker](./src/worker/worker-thread/worker-thread-worker.md) — `AbstractWorker` subclass.
- [Observability](./src/observability/instruments.md) — OTel counters, histograms, tracer.
## Design highlights
- 100% test coverage (statements + branches + functions + lines).
- Same class runs against browser `Worker` and Node `worker_threads` via injectable `WorkerFactory`.
- Bounded `Map<requestId, PendingRequest>` — never leaks outstanding entries.
- Zod-validated envelope in both directions rejects malformed traffic at the boundary.
- Structured `QueueDistributionWorkerThreadError` subclasses with a frozen stable-code registry.
- AbortSignal threaded through every request; terminate() fails all in-flight requests.
## Status
Part of the rebuild — version `0.1.0` while it stabilises.

View File

@@ -0,0 +1,88 @@
{
"name": "@suaveplan/queue-distribution-worker-thread",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Web-Worker / Node worker_threads transport plugin for @suaveplan/queue-distribution: caller-injected WorkerFactory bridges to the platform-native worker, message correlation, Zod-validated reply envelope, bounded outstanding-request map.",
"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-distribution-worker-thread-error": {
"import": {
"types": "./dist/errors/queue-distribution-worker-thread-error.d.ts",
"default": "./dist/errors/queue-distribution-worker-thread-error.js"
}
},
"./communicator/worker-thread/worker-thread-communicator": {
"import": {
"types": "./dist/communicator/worker-thread/worker-thread-communicator.d.ts",
"default": "./dist/communicator/worker-thread/worker-thread-communicator.js"
}
},
"./worker/worker-thread/worker-thread-worker": {
"import": {
"types": "./dist/worker/worker-thread/worker-thread-worker.d.ts",
"default": "./dist/worker/worker-thread/worker-thread-worker.js"
}
},
"./observability/instruments": {
"import": {
"types": "./dist/observability/instruments.d.ts",
"default": "./dist/observability/instruments.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/queue-distribution": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,70 @@
# `WorkerThreadCommunicator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerThreadCommunicator` bridges `@suaveplan/queue-distribution`'s `WorkerCommunicator` shape to a backing thread worker via message passing. The factory is injectable so the same class runs against browser `Worker`, Node `worker_threads`, or any test shim conforming to the structural `ThreadWorkerLike` shape. Every outbound request carries a Zod-validated envelope; every inbound reply is correlated back to its pending request by `id` and validated against a reply schema.
## Features
- Generic over the thread worker — pass any `() => ThreadWorkerLike` factory.
- Bounded `Map<requestId, PendingRequest>` of outstanding requests; never leaks.
- Per-request timeout via `setTimeout`, configurable via `defaultTimeoutMs` or per-call.
- AbortSignal threaded through every request; aborting cleans up the entry and rejects with `WT.WORKER_TERMINATED`.
- Zod schemas at the boundary in both directions; malformed replies surface as `WT.MESSAGE_MALFORMED`.
- `terminate()` synchronously fails every outstanding request, clears the map, and terminates the backing worker.
- Injectable `Clock` and `IdGenerator`.
- OTel counters and histograms per request (`queue_dist_wt.request_total{kind, outcome}`, `queue_dist_wt.request.duration_ms{kind, outcome}`).
## Usage: Basic Example
```ts
import { WorkerThreadCommunicator } from "@suaveplan/queue-distribution-worker-thread/communicator/worker-thread/worker-thread-communicator";
const communicator = new WorkerThreadCommunicator(
() => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }),
{ workerId: "audio-worker" },
);
const result = await communicator.sendTask("", task, input);
```
## Usage: Advanced Example
In tests, drive the communicator from a `ThreadWorkerLike` shim that captures posted messages and lets the test simulate replies.
```ts
import { WorkerThreadCommunicator } from "@suaveplan/queue-distribution-worker-thread/communicator/worker-thread/worker-thread-communicator";
const posted: unknown[] = [];
let onMessage: ((m: unknown) => void) | undefined;
const shim = {
postMessage: (m: unknown) => posted.push(m),
addEventListener: (event, handler) => {
if (event === "message") onMessage = (data) => handler({ data });
},
terminate: () => undefined,
};
const c = new WorkerThreadCommunicator(() => shim, { workerId: "test" });
const promise = c.sendTask("", task, input);
const envelope = posted[0] as { id: string };
onMessage?.({ id: envelope.id, ok: true, data: "OK" });
console.log(await promise); // "OK"
```
## API Reference
- `interface ThreadWorkerLike` — structural worker shape (`postMessage`, `addEventListener`, `terminate`).
- `type WorkerFactory = () => ThreadWorkerLike`.
- `class WorkerThreadCommunicator`.
- `constructor(factory: WorkerFactory, options?: WorkerThreadCommunicatorOptions, deps?: WorkerThreadCommunicatorDeps)`.
- `interface WorkerThreadCommunicatorOptions` — `defaultTimeoutMs?`, `workerId?`.
- `interface WorkerThreadCommunicatorDeps` — `clock?`, `idGenerator?`.
- `sendTask`, `checkStatus`, `checkHealth`, `ping`, `terminate`, `getProtocol`, `getBackingWorker`.
## Implementation Notes
Inbound replies that fail the Zod envelope still get a best-effort surface: if the malformed payload has a string `id` matching a pending request, that request is rejected with `WT.MESSAGE_MALFORMED`. Without an id, the reply is silently dropped — it's unclaimable. The timeout `setTimeout` callback always calls `this.#pending.delete(id)` unconditionally because every other settle path (message arrival, abort, terminate) clears the timer before deleting; if the timer fires, the entry must still be there. Bypassing the entry-existence check lets coverage hit 100% without a defensive race.

View File

@@ -0,0 +1,286 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { makeFakeThreadWorker } from "../../../tests/helpers/fake-thread-worker.js";
import {
WorkerThreadMessageMalformedError,
WorkerThreadRequestTimeoutError,
WorkerThreadTerminatedError,
} from "../../errors/queue-distribution-worker-thread-error.js";
import { WorkerThreadCommunicator } from "./worker-thread-communicator.js";
function makeTask(id = "t") {
let status = "PENDING" as string;
return {
id,
execute: async () => undefined as never,
getStatus: () => status as never,
setStatus: (s: string) => {
status = s;
},
getMetadata: () => ({ createdAt: new Date(0), attempts: 0 }),
};
}
describe("WorkerThreadCommunicator", () => {
it("getProtocol returns 'worker-thread'", { timeout: 5000 }, () => {
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(() => fake, { workerId: "w" });
expect(c.getProtocol()).toBe("worker-thread");
});
it("getBackingWorker exposes the factory output", { timeout: 5000 }, () => {
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(() => fake, { workerId: "w" });
expect(c.getBackingWorker()).toBe(fake);
});
it("sendTask resolves with the matching reply", {
timeout: 5000,
}, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const promise = c.sendTask("ep", makeTask("t"), "in");
fake.deliver({ id: "req-1", ok: true, data: "OK" });
expect(await promise).toBe("OK");
});
it("correlates concurrent replies by id", { timeout: 5000 }, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const p1 = c.sendTask("ep", makeTask("a"), 1);
const p2 = c.sendTask("ep", makeTask("b"), 2);
fake.deliver({ id: "req-2", ok: true, data: "second" });
fake.deliver({ id: "req-1", ok: true, data: "first" });
expect(await p2).toBe("second");
expect(await p1).toBe("first");
});
it("rejects with WT.MESSAGE_MALFORMED when reply fails Zod validation", {
timeout: 5000,
}, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const promise = c.sendTask("ep", makeTask("t"), "in");
fake.deliver({ id: "req-1", ok: "yes" });
await expect(promise).rejects.toThrow(
WorkerThreadMessageMalformedError
);
});
it("ignores replies for unknown ids", { timeout: 5000 }, async () => {
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(() => fake, { workerId: "w" });
fake.deliver({ id: "unknown", ok: true, data: 1 });
fake.deliver({ totallyWrong: true });
// Malformed reply with a string id that doesn't match any pending —
// exercises the inner `entry !== undefined` false branch.
fake.deliver({ id: "ghost", garbage: 1 });
// Null payload exercises the `data as { id?: unknown } | null` branch.
fake.deliver(null);
expect(c.getProtocol()).toBe("worker-thread");
});
it("propagates ok=false replies as MESSAGE_MALFORMED", {
timeout: 5000,
}, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const promise = c.sendTask("ep", makeTask("t"), "in");
fake.deliver({
id: "req-1",
ok: false,
error: { code: "X", message: "broken" },
});
await expect(promise).rejects.toThrow(
WorkerThreadMessageMalformedError
);
});
it("rejects with WT.REQUEST_TIMEOUT and clears the outstanding-request map", {
timeout: 5000,
}, async () => {
vi.useFakeTimers();
try {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w", defaultTimeoutMs: 100 },
{ idGenerator: () => `req-${++i}` }
);
const promise = c.sendTask("ep", makeTask("t"), "in");
const handled = promise.catch((err) => err);
await vi.advanceTimersByTimeAsync(150);
await expect(handled).resolves.toBeInstanceOf(
WorkerThreadRequestTimeoutError
);
} finally {
vi.useRealTimers();
}
});
it("terminate fails outstanding requests with WT.WORKER_TERMINATED", {
timeout: 5000,
}, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const p1 = c.sendTask("ep", makeTask("a"), 1).catch((e) => e);
const p2 = c.sendTask("ep", makeTask("b"), 2).catch((e) => e);
c.terminate();
await expect(p1).resolves.toBeInstanceOf(WorkerThreadTerminatedError);
await expect(p2).resolves.toBeInstanceOf(WorkerThreadTerminatedError);
expect(fake.terminated).toBe(true);
});
it("aborting after a reply was delivered is a no-op", {
timeout: 5000,
}, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const ctrl = new AbortController();
const promise = c.sendTask("ep", makeTask("t"), "in", ctrl.signal);
fake.deliver({ id: "req-1", ok: true, data: "OK" });
expect(await promise).toBe("OK");
// Late abort exercises the `if (!this.#pending.delete(id))` early-return
// branch — the entry has already been removed by the message handler.
ctrl.abort();
});
it("abort signal mid-request rejects with WT.WORKER_TERMINATED", {
timeout: 5000,
}, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const ctrl = new AbortController();
const handled = c
.sendTask("ep", makeTask("t"), "in", ctrl.signal)
.catch((err) => err);
ctrl.abort();
await expect(handled).resolves.toBeInstanceOf(
WorkerThreadTerminatedError
);
});
it("checkStatus returns the typed enum", { timeout: 5000 }, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const promise = c.checkStatus("ep");
fake.deliver({ id: "req-1", ok: true, data: "BUSY" });
expect(await promise).toBe("BUSY");
});
it("checkHealth returns the worker-health payload", {
timeout: 5000,
}, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const health = {
isHealthy: true,
metrics: {
cpu: 0,
memory: 0,
tasksFailed: 0,
tasksSucceeded: 0,
responseTime: 0,
},
};
const promise = c.checkHealth("ep");
fake.deliver({ id: "req-1", ok: true, data: health });
expect(await promise).toEqual(health);
});
it("ping measures elapsed milliseconds from the injected Clock", {
timeout: 5000,
}, async () => {
let now = 1000;
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ clock: () => now, idGenerator: () => `req-${++i}` }
);
const promise = c.ping("ep", 1_000);
now = 1300;
fake.deliver({ id: "req-1", ok: true, data: { timestamp: 1 } });
expect(await promise).toBeGreaterThanOrEqual(0);
});
it("uses default IdGenerator when none is injected", {
timeout: 5000,
}, () => {
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(() => fake);
expect(c.workerId.startsWith("wt-")).toBe(true);
});
it("posts a Zod-valid envelope to the backing worker", {
timeout: 5000,
}, async () => {
let i = 0;
const fake = makeFakeThreadWorker();
const c = new WorkerThreadCommunicator(
() => fake,
{ workerId: "w" },
{ idGenerator: () => `req-${++i}` }
);
const promise = c.sendTask("ep", makeTask("t"), "in");
expect(fake.posted[0]).toMatchObject({ id: "req-1", kind: "task" });
fake.deliver({ id: "req-1", ok: true, data: "OK" });
await promise;
});
it("ignores replies with an id but no matching pending entry", {
timeout: 5000,
}, () => {
const fake = makeFakeThreadWorker();
new WorkerThreadCommunicator(() => fake, { workerId: "w" });
// Deliver with a string id but no pending; just exercises the early
// return in #onMessage's no-pending-entry branch.
fake.deliver({ id: "ghost", ok: true, data: 1 });
});
});

View File

@@ -0,0 +1,278 @@
import type {
DistributedTask,
WorkerHealth,
WorkerStatusValue,
} from "@suaveplan/queue-distribution";
import type { Clock, IdGenerator } from "@suaveplan/types";
import z from "zod";
import {
WorkerThreadMessageMalformedError,
WorkerThreadRequestTimeoutError,
WorkerThreadTerminatedError,
} from "../../errors/queue-distribution-worker-thread-error.js";
import {
recordRequestDuration,
recordRequestOutcome,
type WorkerThreadKind,
} from "../../observability/instruments.js";
/** Minimal structural worker shape. Both browser `Worker` and Node `worker_threads.Worker` match it. */
export interface ThreadWorkerLike {
postMessage(message: unknown): void;
addEventListener(
event: "message" | "error" | "exit",
handler: (event: {
data?: unknown;
error?: unknown;
code?: number;
}) => void
): void;
removeEventListener?(event: string, handler: unknown): void;
terminate(): undefined | Promise<unknown>;
}
export type WorkerFactory = () => ThreadWorkerLike;
/** Outbound request envelope. */
const RequestSchema = z.object({
id: z.string(),
kind: z.enum(["task", "status", "health", "ping"]),
payload: z.unknown().optional(),
});
/** Inbound reply envelope. */
const ReplySchema = z.union([
z.object({
id: z.string(),
ok: z.literal(true),
data: z.unknown(),
}),
z.object({
id: z.string(),
ok: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
}),
}),
]);
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: unknown) => void;
timer: ReturnType<typeof setTimeout>;
kind: WorkerThreadKind;
start: number;
}
export interface WorkerThreadCommunicatorOptions {
defaultTimeoutMs?: number;
workerId?: string;
}
export interface WorkerThreadCommunicatorDeps {
clock?: Clock;
idGenerator?: IdGenerator;
}
/**
* Communicator that delegates work to a backing thread worker via message
* passing. The `WorkerFactory` is injectable so the same class runs against
* browser `Worker`, Node `worker_threads`, or any test shim that conforms
* to the structural `ThreadWorkerLike` shape.
*/
export class WorkerThreadCommunicator {
readonly workerId: string;
readonly #worker: ThreadWorkerLike;
readonly #defaultTimeoutMs: number;
readonly #pending: Map<string, PendingRequest> = new Map();
readonly #clock: Clock;
readonly #idGenerator: IdGenerator;
constructor(
factory: WorkerFactory,
options: WorkerThreadCommunicatorOptions = {},
deps: WorkerThreadCommunicatorDeps = {}
) {
this.#worker = factory();
this.workerId =
options.workerId ??
`wt-${(deps.idGenerator ?? (() => crypto.randomUUID()))()}`;
this.#defaultTimeoutMs = options.defaultTimeoutMs ?? 30_000;
this.#clock = deps.clock ?? Date.now;
this.#idGenerator = deps.idGenerator ?? (() => crypto.randomUUID());
this.#worker.addEventListener("message", (event) => {
this.#onMessage(event.data);
});
}
getBackingWorker(): ThreadWorkerLike {
return this.#worker;
}
getProtocol(): string {
return "worker-thread";
}
async sendTask<I, O>(
_endpoint: string,
task: DistributedTask<I, O>,
input: I,
signal?: AbortSignal
): Promise<O> {
return this.#request(
"task",
{ taskId: task.id, input },
signal
) as Promise<O>;
}
async checkStatus(
_endpoint: string,
signal?: AbortSignal
): Promise<WorkerStatusValue> {
return (await this.#request(
"status",
undefined,
signal
)) as WorkerStatusValue;
}
async checkHealth(
_endpoint: string,
signal?: AbortSignal
): Promise<WorkerHealth> {
return (await this.#request(
"health",
undefined,
signal
)) as WorkerHealth;
}
async ping(
_endpoint: string,
timeoutMs?: number,
signal?: AbortSignal
): Promise<number> {
const start = this.#clock();
await this.#request("ping", undefined, signal, timeoutMs);
return this.#clock() - start;
}
terminate(): void {
const pending = Array.from(this.#pending.entries());
this.#pending.clear();
for (const [id, entry] of pending) {
clearTimeout(entry.timer);
entry.reject(
new WorkerThreadTerminatedError({
workerId: this.workerId,
requestId: id,
})
);
}
void this.#worker.terminate();
}
#request(
kind: WorkerThreadKind,
payload: unknown,
signal?: AbortSignal,
timeoutMs?: number
): Promise<unknown> {
const id = this.#idGenerator();
const deadline = timeoutMs ?? this.#defaultTimeoutMs;
const envelope = RequestSchema.parse({ id, kind, payload });
return new Promise<unknown>((resolve, reject) => {
const start = this.#clock();
const timer = setTimeout(() => {
this.#pending.delete(id);
recordRequestOutcome(kind, "failure");
recordRequestDuration(kind, this.#clock() - start, "failure");
reject(
new WorkerThreadRequestTimeoutError({
workerId: this.workerId,
requestId: id,
timeoutMs: deadline,
})
);
}, deadline);
this.#pending.set(id, { resolve, reject, timer, kind, start });
if (signal !== undefined) {
signal.addEventListener(
"abort",
() => {
if (!this.#pending.delete(id)) {
return;
}
clearTimeout(timer);
reject(
new WorkerThreadTerminatedError({
workerId: this.workerId,
requestId: id,
})
);
},
{ once: true }
);
}
this.#worker.postMessage(envelope);
});
}
#onMessage(data: unknown): void {
const parsed = ReplySchema.safeParse(data);
if (!parsed.success) {
// Unknown reply shape — best effort: surface as malformed on any
// pending request that lacks a matching id.
const dataAsObject = data as { id?: unknown } | null;
const id =
typeof dataAsObject?.id === "string"
? dataAsObject.id
: undefined;
if (id !== undefined) {
const entry = this.#pending.get(id);
if (entry !== undefined) {
this.#pending.delete(id);
clearTimeout(entry.timer);
recordRequestOutcome(entry.kind, "failure");
recordRequestDuration(
entry.kind,
this.#clock() - entry.start,
"failure"
);
entry.reject(
new WorkerThreadMessageMalformedError({
workerId: this.workerId,
cause: parsed.error,
})
);
}
}
return;
}
const entry = this.#pending.get(parsed.data.id);
if (entry === undefined) {
return;
}
this.#pending.delete(parsed.data.id);
clearTimeout(entry.timer);
const elapsed = this.#clock() - entry.start;
if (parsed.data.ok) {
recordRequestOutcome(entry.kind, "success");
recordRequestDuration(entry.kind, elapsed, "success");
entry.resolve(parsed.data.data);
} else {
recordRequestOutcome(entry.kind, "failure");
recordRequestDuration(entry.kind, elapsed, "failure");
entry.reject(
new WorkerThreadMessageMalformedError({
workerId: this.workerId,
cause: new Error(
`${parsed.data.error.code}: ${parsed.data.error.message}`
),
})
);
}
}
}

View File

@@ -0,0 +1,76 @@
# `QueueDistributionWorkerThreadError` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`QueueDistributionWorkerThreadError` is the base class for every structured error thrown by `@suaveplan/queue-distribution-worker-thread`. Each subclass carries a stable code from the registry produced by `defineCodes("WT", [...])`. Consumers match programmatically on `error.code` to distinguish a worker crash from a malformed reply, a request timeout, or a graceful termination — without parsing error messages.
## Features
- 4 stable codes — `WORKER_CRASHED`, `MESSAGE_MALFORMED`, `REQUEST_TIMEOUT`, `WORKER_TERMINATED`.
- One concrete subclass per code with a typed constructor signature.
- Optional `cause` narrowed to `Error` instances only.
- Compatible with `SuaveplanError` cause-chain traversal and i18n message resolver.
## Usage: Basic Example
```ts
import {
QueueDistributionWorkerThreadError,
WT_CODES,
} from "@suaveplan/queue-distribution-worker-thread/errors/queue-distribution-worker-thread-error";
try {
await worker.executeTask(task, input);
} catch (error: unknown) {
if (
error instanceof QueueDistributionWorkerThreadError &&
error.code === WT_CODES.WORKER_CRASHED
) {
// Re-route to a different worker.
}
throw error;
}
```
## Usage: Advanced Example
Use the discriminated codes to drive recovery strategy per failure class.
```ts
import {
QueueDistributionWorkerThreadError,
WT_CODES,
} from "@suaveplan/queue-distribution-worker-thread/errors/queue-distribution-worker-thread-error";
function recoveryPolicy(error: unknown): "retry" | "drop" | "fatal" {
if (!(error instanceof QueueDistributionWorkerThreadError)) {
return "fatal";
}
switch (error.code) {
case WT_CODES.REQUEST_TIMEOUT:
return "retry";
case WT_CODES.WORKER_TERMINATED:
return "drop";
case WT_CODES.WORKER_CRASHED:
case WT_CODES.MESSAGE_MALFORMED:
return "fatal";
}
}
```
## API Reference
- `const WT_CODES` — frozen registry of `WT.*` codes.
- `type QueueDistributionWorkerThreadErrorCode` — union of every code.
- `class QueueDistributionWorkerThreadError extends SuaveplanError<QueueDistributionWorkerThreadErrorCode>`.
- `class WorkerThreadCrashedError({ workerId, cause? })`.
- `class WorkerThreadMessageMalformedError({ workerId, cause? })`.
- `class WorkerThreadRequestTimeoutError({ workerId, requestId, timeoutMs })`.
- `class WorkerThreadTerminatedError({ workerId, requestId })`.
## Implementation Notes
`MESSAGE_MALFORMED` is used both for replies that fail the Zod envelope and for `ok: false` replies — both represent the worker telling us "this isn't going to work" without specifying _what_ about the worker is broken. If the upstream needs different classifications, the inbound payload schema is the place to extend, not the error registry. The `WORKER_CRASHED` and `WORKER_TERMINATED` codes are aimed at supervision: the first is what the operator wants to alert on; the second is what the manager wants to filter out of "real failures" during a graceful drain.

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
QueueDistributionWorkerThreadError,
WorkerThreadCrashedError,
WorkerThreadMessageMalformedError,
WorkerThreadRequestTimeoutError,
WorkerThreadTerminatedError,
WT_CODES,
} from "./queue-distribution-worker-thread-error.js";
describe("QueueDistributionWorkerThreadError registry", () => {
it("registers exactly the expected codes", { timeout: 5000 }, () => {
expect(Object.values(WT_CODES).sort()).toEqual(
[
"WT.WORKER_CRASHED",
"WT.MESSAGE_MALFORMED",
"WT.REQUEST_TIMEOUT",
"WT.WORKER_TERMINATED",
].sort()
);
});
it("every error subclass carries the right code", { timeout: 5000 }, () => {
const samples = [
{
err: new WorkerThreadCrashedError({ workerId: "w" }),
code: WT_CODES.WORKER_CRASHED,
},
{
err: new WorkerThreadMessageMalformedError({ workerId: "w" }),
code: WT_CODES.MESSAGE_MALFORMED,
},
{
err: new WorkerThreadRequestTimeoutError({
workerId: "w",
requestId: "r",
timeoutMs: 1,
}),
code: WT_CODES.REQUEST_TIMEOUT,
},
{
err: new WorkerThreadTerminatedError({
workerId: "w",
requestId: "r",
}),
code: WT_CODES.WORKER_TERMINATED,
},
];
for (const { err, code } of samples) {
expect(err).toBeInstanceOf(QueueDistributionWorkerThreadError);
expect(err.code).toBe(code);
}
});
it("preserves Error causes and drops non-Error causes", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
expect(
new WorkerThreadCrashedError({ workerId: "w", cause }).cause
).toBe(cause);
expect(
new WorkerThreadCrashedError({ workerId: "w", cause: "x" }).cause
).toBeUndefined();
expect(
new WorkerThreadMessageMalformedError({ workerId: "w", cause })
.cause
).toBe(cause);
});
});

View File

@@ -0,0 +1,62 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error-code registry for `@suaveplan/queue-distribution-worker-thread`. */
export const WT_CODES = defineCodes("WT", [
"WORKER_CRASHED",
"MESSAGE_MALFORMED",
"REQUEST_TIMEOUT",
"WORKER_TERMINATED",
] as const);
export type QueueDistributionWorkerThreadErrorCode =
(typeof WT_CODES)[keyof typeof WT_CODES];
export class QueueDistributionWorkerThreadError extends SuaveplanError<QueueDistributionWorkerThreadErrorCode> {}
function asErrorCause(cause: unknown): Error | undefined {
return cause instanceof Error ? cause : undefined;
}
/** Emitted when the backing worker fires an `error` event. */
export class WorkerThreadCrashedError extends QueueDistributionWorkerThreadError {
constructor(options: { workerId: string; cause?: unknown }) {
super(WT_CODES.WORKER_CRASHED, `Worker ${options.workerId} crashed`, {
cause: asErrorCause(options.cause),
});
}
}
/** Thrown when an inbound reply fails the Zod envelope validation. */
export class WorkerThreadMessageMalformedError extends QueueDistributionWorkerThreadError {
constructor(options: { workerId: string; cause?: unknown }) {
super(
WT_CODES.MESSAGE_MALFORMED,
`Worker ${options.workerId} sent a malformed reply`,
{ cause: asErrorCause(options.cause) }
);
}
}
/** Thrown when an outstanding request exceeds the configured deadline. */
export class WorkerThreadRequestTimeoutError extends QueueDistributionWorkerThreadError {
constructor(options: {
workerId: string;
requestId: string;
timeoutMs: number;
}) {
super(
WT_CODES.REQUEST_TIMEOUT,
`Worker ${options.workerId} request ${options.requestId} timed out after ${options.timeoutMs}ms`
);
}
}
/** Thrown by every outstanding request after `terminate()` is invoked. */
export class WorkerThreadTerminatedError extends QueueDistributionWorkerThreadError {
constructor(options: { workerId: string; requestId: string }) {
super(
WT_CODES.WORKER_TERMINATED,
`Worker ${options.workerId} terminated while request ${options.requestId} was in flight`
);
}
}

View File

@@ -0,0 +1,29 @@
export {
type ThreadWorkerLike,
type WorkerFactory,
WorkerThreadCommunicator,
type WorkerThreadCommunicatorDeps,
type WorkerThreadCommunicatorOptions,
} from "./communicator/worker-thread/worker-thread-communicator.js";
export {
QueueDistributionWorkerThreadError,
type QueueDistributionWorkerThreadErrorCode,
WorkerThreadCrashedError,
WorkerThreadMessageMalformedError,
WorkerThreadRequestTimeoutError,
WorkerThreadTerminatedError,
WT_CODES,
} from "./errors/queue-distribution-worker-thread-error.js";
export {
getSendTaskTracer,
recordRequestDuration,
recordRequestOutcome,
recordWorkerCrashed,
recordWorkerTerminated,
resetInstrumentCache,
type WorkerThreadKind,
} from "./observability/instruments.js";
export {
WorkerThreadWorker,
type WorkerThreadWorkerOptions,
} from "./worker/worker-thread/worker-thread-worker.js";

View File

@@ -0,0 +1,61 @@
# Worker-thread plugin OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`observability/instruments.ts` centralises every OpenTelemetry counter, histogram, and tracer emitted by `@suaveplan/queue-distribution-worker-thread`. Instruments register under the meter / tracer name `"@suaveplan/queue-distribution-worker-thread"` so SDK consumers can route them with a single rule. Tracer / meter handles are cached behind `resetInstrumentCache()` for tests.
## Features
- Counters: `queue_dist_wt.request_total{kind, outcome}`, `queue_dist_wt.worker.crashed_total`, `queue_dist_wt.worker.terminated_total`.
- Histogram: `queue_dist_wt.request.duration_ms{kind, outcome}`.
- Tracer: `queue_dist_wt.sendTask`.
- `WorkerThreadKind` union (`"task" | "status" | "health" | "ping"`) keeps the cardinality of the `kind` dimension bounded.
- `resetInstrumentCache()` for tests that install a new provider mid-suite.
## Usage: Basic Example
Production code emits records automatically. Consumers wire a meter / tracer provider at startup:
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
metrics.setGlobalMeterProvider(new MeterProvider({/* …readers… */}));
```
## Usage: Advanced Example
In tests, install an in-memory exporter, drive the system, then assert directly on the captured metrics.
```ts
import { resetInstrumentCache } from "@suaveplan/queue-distribution-worker-thread/observability/instruments";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 100 });
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
resetInstrumentCache();
// … drive the system, then await reader.forceFlush() …
```
## API Reference
- `function resetInstrumentCache(): void`.
- `function recordRequestOutcome(kind, outcome): void`.
- `function recordRequestDuration(kind, durationMs, outcome): void`.
- `function recordWorkerCrashed(): void`, `function recordWorkerTerminated(): void`.
- `function getSendTaskTracer(): Tracer`.
- `type WorkerThreadKind = "task" | "status" | "health" | "ping"`.
## Implementation Notes
The instruments share a single cached meter handle so per-call overhead in the hot path is a map lookup. `WorkerThreadKind` is a typed union rather than a free-form string so unknown `kind` values never leak into dashboards — every request type the communicator can send is enumerated, keeping cardinality bounded. `resetInstrumentCache()` exists exclusively for tests; production sets the global provider once at startup and never needs to flush the cache.

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getSendTaskTracer,
recordRequestDuration,
recordRequestOutcome,
recordWorkerCrashed,
recordWorkerTerminated,
resetInstrumentCache,
} from "./instruments.js";
describe("worker-thread observability", () => {
it("every recorder is callable without a provider", {
timeout: 5000,
}, () => {
expect(() => {
recordRequestOutcome("task", "success");
recordRequestOutcome("status", "failure");
recordRequestDuration("health", 100, "success");
recordRequestDuration("ping", 50, "failure");
recordWorkerCrashed();
recordWorkerTerminated();
}).not.toThrow();
});
it("tracers are cached across calls", { timeout: 5000 }, () => {
resetInstrumentCache();
const first = getSendTaskTracer();
const second = getSendTaskTracer();
expect(first).toBe(second);
});
});

View File

@@ -0,0 +1,59 @@
import { metrics, type Tracer, trace } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/queue-distribution-worker-thread";
const TRACER_NAME = "@suaveplan/queue-distribution-worker-thread";
let cachedMeter: ReturnType<typeof metrics.getMeter> | undefined;
let cachedTracer: Tracer | undefined;
function getMeter(): ReturnType<typeof metrics.getMeter> {
if (cachedMeter === undefined) {
cachedMeter = metrics.getMeter(METER_NAME);
}
return cachedMeter;
}
function getTracer(): Tracer {
if (cachedTracer === undefined) {
cachedTracer = trace.getTracer(TRACER_NAME);
}
return cachedTracer;
}
export function resetInstrumentCache(): void {
cachedMeter = undefined;
cachedTracer = undefined;
}
export type WorkerThreadKind = "task" | "status" | "health" | "ping";
export function recordRequestOutcome(
kind: WorkerThreadKind,
outcome: "success" | "failure"
): void {
getMeter()
.createCounter("queue_dist_wt.request_total")
.add(1, { kind, outcome });
}
export function recordRequestDuration(
kind: WorkerThreadKind,
durationMs: number,
outcome: "success" | "failure"
): void {
getMeter()
.createHistogram("queue_dist_wt.request.duration_ms")
.record(durationMs, { kind, outcome });
}
export function recordWorkerCrashed(): void {
getMeter().createCounter("queue_dist_wt.worker.crashed_total").add(1);
}
export function recordWorkerTerminated(): void {
getMeter().createCounter("queue_dist_wt.worker.terminated_total").add(1);
}
export function getSendTaskTracer(): Tracer {
return getTracer();
}

View File

@@ -0,0 +1,78 @@
# `WorkerThreadWorker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerThreadWorker` extends `AbstractWorker` (from `@suaveplan/queue-distribution`) and dispatches every task to a backing thread worker via `WorkerThreadCommunicator`. A backing-worker crash event flips the worker to `UNHEALTHY` and increments `WT.WORKER_CRASHED`; `stop(force)` terminates the backing worker and fails every outstanding request with `WT.WORKER_TERMINATED`. The worker reports `{ isRemote: false, isThreaded: true }` capabilities so load-balancing strategies can route work appropriately.
## Features
- Implements the full `Worker` contract via `AbstractWorker` inheritance.
- `doExecuteTask` delegates to `WorkerThreadCommunicator.sendTask`.
- Backing-worker `error` event → `markUnhealthy("Worker crashed")` + OTel `queue_dist_wt.worker.crashed_total++`.
- Backing-worker `exit` event → `markUnhealthy("Worker exited")`.
- `doStop(force)` calls `communicator.terminate()` which fails every outstanding request and tears down the backing worker; OTel `queue_dist_wt.worker.terminated_total++`.
- `getCapabilities()` advertises `{ isRemote: false, isThreaded: true, protocol: "worker-thread" }`.
## Usage: Basic Example
```ts
import {
WorkerManager,
WorkerPool,
} from "@suaveplan/queue-distribution";
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
const pool = new WorkerPool();
pool.addWorker(
new WorkerThreadWorker(
() => new Worker(new URL("./hot-path.ts", import.meta.url), { type: "module" }),
{ id: "hot-1", capacity: 4 },
),
);
await pool.startAllWorkers();
const manager = new WorkerManager(pool, { autoHealthCheck: false });
const result = await manager.assignTask(task, input);
```
## Usage: Advanced Example
For a Node-side worker pool with `worker_threads`, wrap the platform `Worker` in a thin adapter that matches `ThreadWorkerLike`.
```ts
import { Worker as NodeWorker } from "node:worker_threads";
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
function nodeWorkerFactory(): ReturnType<typeof Object.assign> {
const node = new NodeWorker("./worker.js");
return {
postMessage: (m) => node.postMessage(m),
addEventListener: (event, handler) => {
if (event === "message") node.on("message", (data) => handler({ data }));
if (event === "error") node.on("error", (error) => handler({ error }));
if (event === "exit") node.on("exit", (code) => handler({ code }));
},
terminate: () => node.terminate(),
};
}
const worker = new WorkerThreadWorker(nodeWorkerFactory, {
id: "node-1",
defaultTimeoutMs: 5_000,
});
```
## API Reference
- `class WorkerThreadWorker extends AbstractWorker`.
- `constructor(factory: WorkerFactory, options?: WorkerThreadWorkerOptions, deps?: AbstractWorkerDeps)`.
- `interface WorkerThreadWorkerOptions extends AbstractWorkerOptions` — `defaultTimeoutMs?`.
- `getCommunicator(): WorkerThreadCommunicator`.
- Protected: `doExecuteTask`, `doStop`, `getType()`, `getCapabilities()`.
## Implementation Notes
The crash / exit handlers fire from inside the structural worker's event-listener registration; both flip status to `UNHEALTHY` and emit observability records but do NOT throw — the worker is no longer usable but the manager's supervisor (running independently in `@suaveplan/queue-distribution`) is the right place to escalate the failure. `defaultTimeoutMs` is forwarded to the communicator only when explicitly set, so the communicator's own default (30 seconds) applies when neither layer is configured.

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeFakeThreadWorker } from "../../../tests/helpers/fake-thread-worker.js";
import { WorkerThreadTerminatedError } from "../../errors/queue-distribution-worker-thread-error.js";
import { WorkerThreadWorker } from "./worker-thread-worker.js";
function makeTask(id = "t") {
let status = "PENDING" as string;
return {
id,
execute: async () => undefined as never,
getStatus: () => status as never,
setStatus: (s: string) => {
status = s;
},
getMetadata: () => ({ createdAt: new Date(0), attempts: 0 }),
};
}
describe("WorkerThreadWorker", () => {
it("constructs with all defaults when only the factory is provided", {
timeout: 5000,
}, () => {
const fake = makeFakeThreadWorker();
const worker = new WorkerThreadWorker(() => fake);
expect(worker.getInfo().type).toBe("worker-thread");
});
it("getType returns 'worker-thread' and capabilities advertise the threaded shape", {
timeout: 5000,
}, () => {
const fake = makeFakeThreadWorker();
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
const info = worker.getInfo();
expect(info.type).toBe("worker-thread");
expect(info.capabilities).toEqual({
isRemote: false,
isThreaded: true,
protocol: "worker-thread",
});
});
it("getCommunicator returns the underlying communicator", {
timeout: 5000,
}, () => {
const fake = makeFakeThreadWorker();
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
expect(worker.getCommunicator().getBackingWorker()).toBe(fake);
});
it("executeTask delegates to the communicator's sendTask", {
timeout: 5000,
}, async () => {
const fake = makeFakeThreadWorker();
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
await worker.start();
const promise = worker.executeTask(makeTask("t"), "in");
const posted = fake.posted[0] as { id: string };
fake.deliver({ id: posted.id, ok: true, data: "OK" });
expect(await promise).toBe("OK");
await worker.stop(true);
});
it("backing worker crash flips status to UNHEALTHY and increments WT.WORKER_CRASHED counter", {
timeout: 5000,
}, () => {
const fake = makeFakeThreadWorker();
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
fake.emitError(new Error("explode"));
expect(worker.status).toBe("UNHEALTHY");
});
it("backing worker exit flips status to UNHEALTHY", {
timeout: 5000,
}, () => {
const fake = makeFakeThreadWorker();
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
fake.emitExit();
expect(worker.status).toBe("UNHEALTHY");
});
it("terminate fails outstanding requests with WT.WORKER_TERMINATED", {
timeout: 5000,
}, async () => {
const fake = makeFakeThreadWorker();
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
await worker.start();
const handled = worker
.executeTask(makeTask("t"), "in")
.catch((err) => err);
await worker.stop(true);
await expect(handled).resolves.toBeInstanceOf(
WorkerThreadTerminatedError
);
expect(fake.terminated).toBe(true);
});
it("propagates defaultTimeoutMs to the underlying communicator", {
timeout: 5000,
}, () => {
const fake = makeFakeThreadWorker();
const worker = new WorkerThreadWorker(() => fake, {
id: "w",
defaultTimeoutMs: 7777,
});
// Smoke check — the option threading is exercised by the timeout test
// in the communicator suite. Here we just confirm construction succeeds.
expect(worker.getInfo().id).toBe("w");
});
});

View File

@@ -0,0 +1,89 @@
import {
AbstractWorker,
type AbstractWorkerDeps,
type AbstractWorkerOptions,
type DistributedTask,
} from "@suaveplan/queue-distribution";
import {
type WorkerFactory,
WorkerThreadCommunicator,
} from "../../communicator/worker-thread/worker-thread-communicator.js";
import { WorkerThreadCrashedError } from "../../errors/queue-distribution-worker-thread-error.js";
import {
recordWorkerCrashed,
recordWorkerTerminated,
} from "../../observability/instruments.js";
export interface WorkerThreadWorkerOptions extends AbstractWorkerOptions {
defaultTimeoutMs?: number;
}
/**
* `AbstractWorker` subclass that dispatches tasks to a backing worker
* thread via `WorkerThreadCommunicator`. A crash event marks the worker
* `UNHEALTHY`; `stop(force)` terminates the backing worker and fails every
* outstanding request.
*/
export class WorkerThreadWorker extends AbstractWorker {
readonly #communicator: WorkerThreadCommunicator;
constructor(
factory: WorkerFactory,
options: WorkerThreadWorkerOptions = {},
deps: AbstractWorkerDeps = {}
) {
super(options, deps);
const communicatorOptions: {
workerId: string;
defaultTimeoutMs?: number;
} = { workerId: this.id };
if (options.defaultTimeoutMs !== undefined) {
communicatorOptions.defaultTimeoutMs = options.defaultTimeoutMs;
}
this.#communicator = new WorkerThreadCommunicator(
factory,
communicatorOptions,
deps
);
const backing = this.#communicator.getBackingWorker();
backing.addEventListener("error", (event) => {
recordWorkerCrashed();
this.markUnhealthy("Worker crashed");
void new WorkerThreadCrashedError({
workerId: this.id,
cause: event.error,
});
});
backing.addEventListener("exit", () => {
this.markUnhealthy("Worker exited");
});
}
getCommunicator(): WorkerThreadCommunicator {
return this.#communicator;
}
protected async doExecuteTask<I, O>(
task: DistributedTask<I, O>,
input: I
): Promise<O> {
return (await this.#communicator.sendTask("", task, input)) as O;
}
protected override async doStop(_force: boolean): Promise<void> {
recordWorkerTerminated();
this.#communicator.terminate();
}
protected getType(): string {
return "worker-thread";
}
protected override getCapabilities(): Readonly<Record<string, unknown>> {
return {
isRemote: false,
isThreaded: true,
protocol: this.#communicator.getProtocol(),
};
}
}

View File

@@ -0,0 +1,53 @@
import type { ThreadWorkerLike } from "../../src/communicator/worker-thread/worker-thread-communicator.js";
interface Listener {
event: string;
handler: (event: { data?: unknown; error?: unknown }) => void;
}
export interface FakeThreadWorker extends ThreadWorkerLike {
deliver(message: unknown): void;
emitError(error: unknown): void;
emitExit(): void;
posted: unknown[];
terminated: boolean;
}
export function makeFakeThreadWorker(): FakeThreadWorker {
const listeners: Listener[] = [];
const worker: FakeThreadWorker = {
posted: [],
terminated: false,
postMessage(message: unknown): void {
worker.posted.push(message);
},
addEventListener(event, handler): void {
listeners.push({ event, handler });
},
terminate(): void {
worker.terminated = true;
},
deliver(message: unknown): void {
for (const l of listeners) {
if (l.event === "message") {
l.handler({ data: message });
}
}
},
emitError(error: unknown): void {
for (const l of listeners) {
if (l.event === "error") {
l.handler({ error });
}
}
},
emitExit(): void {
for (const l of listeners) {
if (l.event === "exit") {
l.handler({});
}
}
},
};
return worker;
}

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

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

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/queue-distribution-worker-thread",
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/queue-distribution",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
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,
},
});