Files
suaveplan/openspec/specs/queue-distribution-worker-thread/spec.md
2026-05-27 23:29:53 +01:00

84 lines
6.3 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: queue-distribution-worker-thread (enterprise-queue-distribution)
**Package**: `@suaveplan/queue-distribution-worker-thread`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: WorkerFactory-driven communicator covering Web-Worker and `worker_threads`
The package SHALL expose `WorkerThreadCommunicator` implementing `WorkerCommunicator` from `@suaveplan/queue-distribution-http` (re-exported `WorkerCommunicator` interface). The constructor SHALL accept an injectable `WorkerFactory` of shape `() => { postMessage(msg, transfer?); addEventListener(kind, handler); terminate(); }` so the same class works against `globalThis.Worker` (browser) and a thin `worker_threads` wrapper (Node/Bun) without importing platform APIs at module scope. The implementation SHALL maintain a `Map<requestId, { resolve, reject, timeoutHandle }>` of outstanding requests. Each outgoing message SHALL include an `id` (generated via injected `IdGenerator`), a `kind: "task" | "status" | "health" | "ping"`, and a `payload` validated by Zod. Reply correlation SHALL match `id`; unknown `id` replies SHALL be logged and dropped. Reply payloads SHALL be Zod-validated against per-kind schemas; failures SHALL reject the in-flight request with `QueueDistributionWorkerThreadError(WT.MESSAGE_MALFORMED)`. Each request SHALL be subject to a per-call timeout (default `defaultTimeoutMs`) honoured by the injected `Clock`; expiry SHALL reject with `WT.REQUEST_TIMEOUT` and remove the entry from the map.
#### Scenario: round-trip correlation
- **WHEN** `sendTask`/`checkStatus`/`checkHealth`/`ping` are invoked concurrently and the underlying worker replies in arbitrary order
- **THEN** each promise resolves with its own reply payload (matched by `id`)
#### Scenario: malformed reply throws WT.MESSAGE_MALFORMED
- **WHEN** the underlying worker posts back `{ id: <known>, kind: "task", payload: { something: "bad" } }` not conforming to the task-reply Zod schema
- **THEN** the in-flight `sendTask` promise rejects with `QueueDistributionWorkerThreadError(WT.MESSAGE_MALFORMED)`
#### Scenario: request timeout
- **WHEN** a `FakeClock` is injected and the worker never replies within `defaultTimeoutMs`
- **THEN** after the clock advances past the deadline, the call rejects with `QueueDistributionWorkerThreadError(WT.REQUEST_TIMEOUT)` AND the outstanding-request map no longer contains the entry
**Verified by:** `src/communicator/worker-thread/worker-thread-communicator.test.ts::"correlates concurrent replies by id"` AND `src/communicator/worker-thread/worker-thread-communicator.test.ts::"rejects with WT.MESSAGE_MALFORMED when reply fails Zod validation"` AND `src/communicator/worker-thread/worker-thread-communicator.test.ts::"rejects with WT.REQUEST_TIMEOUT and clears the outstanding-request map"`
---
### Requirement: WorkerThreadWorker — `AbstractWorker` subclass dispatching to a backing worker
The package SHALL ship `WorkerThreadWorker` extending `AbstractWorker` from `@suaveplan/queue-distribution`. Constructor SHALL accept an injectable `WorkerFactory` and instantiate a fresh `WorkerThreadCommunicator` per worker, AND a single backing worker thread. `doStart()` SHALL register error / exit listeners on the backing worker — `worker.exit` events SHALL `markUnhealthy("Worker exited")` and flip `status` to `OFFLINE`; uncaught `error` events SHALL emit the `WT.WORKER_CRASHED` OTel counter. `doStop(force)` SHALL `terminate()` the backing worker, fail every outstanding request with `WT.WORKER_TERMINATED`, and clear the outstanding-request map. `doExecuteTask` SHALL delegate to `communicator.sendTask(<no-endpoint>, task, input, signal)`. `getCapabilities()` SHALL return `{ isRemote: false, isThreaded: true }`. `getType()` SHALL return `"worker-thread"`.
#### Scenario: backing worker crash promotes to UNHEALTHY
- **WHEN** the backing worker emits an `error` event
- **THEN** the OTel `queue_dist_wt.worker.crashed_total` counter increments and the worker is marked UNHEALTHY
#### Scenario: terminate fails outstanding requests with WT.WORKER_TERMINATED
- **WHEN** two `sendTask` calls are in flight and `worker.stop(true)` is invoked
- **THEN** both pending promises reject with `QueueDistributionWorkerThreadError(WT.WORKER_TERMINATED)` AND `worker.status === OFFLINE`
**Verified by:** `src/worker/worker-thread/worker-thread-worker.test.ts::"backing worker crash flips status to UNHEALTHY and increments WT.WORKER_CRASHED counter"` AND `src/worker/worker-thread/worker-thread-worker.test.ts::"terminate fails outstanding requests with WT.WORKER_TERMINATED"`
---
### Requirement: Stable error registry for the worker-thread plugin
The package SHALL register every error code through `defineCodes("WT", [...])` from `@suaveplan/error` and expose `QueueDistributionWorkerThreadError extends SuaveplanError<WtErrorCode>`. The exhaustive set is: `WORKER_CRASHED`, `MESSAGE_MALFORMED`, `REQUEST_TIMEOUT`, `WORKER_TERMINATED`. Every code SHALL be thrown by ≥1 production call path.
#### Scenario: every registered code is thrown
- **WHEN** the test suite iterates over every code in the registry
- **THEN** a corresponding production throw site exists
**Verified by:** `src/errors/queue-distribution-worker-thread-error.test.ts::"every registered WT.* code is thrown by a production call path"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL emit, via `@suaveplan/opentelemetry` under meter name `"@suaveplan/queue-distribution-worker-thread"`:
- histogram: `queue_dist_wt.request.duration_ms` (attributes `kind`, `outcome`)
- counter: `queue_dist_wt.request_total` (attributes `kind`, `outcome`)
- counter: `queue_dist_wt.worker.crashed_total`
- counter: `queue_dist_wt.worker.terminated_total`
- span: `queue_dist_wt.sendTask`
#### Scenario: histogram emission on sendTask round-trip
- **WHEN** `sendTask` resolves successfully under an in-memory `MeterProvider`
- **THEN** one observation is recorded on `queue_dist_wt.request.duration_ms` with `kind === "task"` AND `outcome === "success"`
**Verified by:** `src/communicator/worker-thread/worker-thread-communicator.spec-conformance.test.ts::"emits queue_dist_wt.request.duration_ms histogram with kind and outcome attributes"`
<!-- markdownlint-enable MD024 -->