Initial Commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: queue-distribution-http (enterprise-queue-distribution)
|
||||
|
||||
**Package**: `@suaveplan/queue-distribution-http`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Worker communicator interface and abstract base
|
||||
|
||||
The package SHALL expose `WorkerCommunicator` with `sendTask<I, O>(endpoint, task, input, signal?): Promise<O>`, `checkStatus(endpoint, signal?): Promise<WorkerStatus>`, `checkHealth(endpoint, signal?): Promise<WorkerHealth>`, `connect(endpoint, signal?): Promise<void>`, `disconnect(endpoint, signal?): Promise<void>`, `ping(endpoint, timeoutMs?, signal?): Promise<number>`, `getProtocol(): string`. `AbstractWorkerCommunicator` SHALL provide constructor-injected `defaultTimeoutMs` and `headers`, plus protected `serialize`/`deserialize`/`createTimeout`/`withTimeout` helpers. The timeout race SHALL use an injected `Clock`, not `setTimeout` at module scope.
|
||||
|
||||
#### Scenario: withTimeout honours injected Clock
|
||||
|
||||
- **WHEN** a `FakeClock` is injected and a never-resolving promise is passed to `withTimeout(promise, 100)`
|
||||
- **THEN** after advancing the clock by 100 ms, `withTimeout` rejects with `QueueDistributionHttpError(HTTP.TIMEOUT)`
|
||||
|
||||
**Verified by:** `src/communicator/base/base.test.ts::"withTimeout rejects with HTTP.TIMEOUT after clock advances past deadline"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: HTTP communicator with injectable fetch and Zod-validated wire envelope
|
||||
|
||||
The package SHALL ship `HttpWorkerCommunicator` extending `AbstractWorkerCommunicator`. The class SHALL accept an injectable `fetch` function (default: `globalThis.fetch`). All responses SHALL be parsed against a Zod schema `WorkerApiResponseSchema<T>` shaped `{ success: boolean; data?: T; error?: { code: string; message: string; details?: unknown } }`. Non-conformant responses SHALL throw `QueueDistributionHttpError(HTTP.RESPONSE_MALFORMED)`. HTTP non-2xx OR `success === false` SHALL throw `QueueDistributionHttpError(HTTP.PROTOCOL_ERROR)` carrying the upstream `error.code` and `error.message` on `cause`. Network-layer `fetch` rejections SHALL throw `QueueDistributionHttpError(HTTP.REQUEST_FAILED)` with the original error on `cause`. The implementation SHALL forward AbortSignal to `fetch`. `getProtocol()` SHALL return `"http"`. `sendTask` SHALL POST `{ taskId, taskMetadata, input }` to `<endpoint>/execute`. `checkStatus` SHALL GET `<endpoint>/status`. `checkHealth` SHALL GET `<endpoint>/health`. `ping` SHALL GET `<endpoint>/ping` and return the round-trip duration via the injected `Clock`.
|
||||
|
||||
#### Scenario: sendTask happy path
|
||||
|
||||
- **WHEN** an injected `fetch` returns `{ success: true, data: <O> }` at `<endpoint>/execute`
|
||||
- **THEN** `sendTask` resolves with the typed `<O>` value AND the `queue_dist_http.request.duration_ms` histogram emits one observation
|
||||
|
||||
#### Scenario: response envelope rejection
|
||||
|
||||
- **WHEN** an injected `fetch` returns `{ foo: 1 }` (no `success` field)
|
||||
- **THEN** `sendTask` rejects with `QueueDistributionHttpError(HTTP.RESPONSE_MALFORMED)`
|
||||
|
||||
#### Scenario: protocol error carries upstream code
|
||||
|
||||
- **WHEN** an injected `fetch` returns 500 with body `{ success: false, error: { code: "INTERNAL", message: "boom" } }`
|
||||
- **THEN** `sendTask` rejects with `QueueDistributionHttpError(HTTP.PROTOCOL_ERROR)` and `error.cause.message === "boom"`
|
||||
|
||||
#### Scenario: AbortSignal forwarded to fetch
|
||||
|
||||
- **WHEN** `sendTask(endpoint, task, input, signal)` is called and `signal.abort()` fires before fetch resolves
|
||||
- **THEN** the call rejects, the injected fetch sees `init.signal.aborted === true`, AND the error code is `HTTP.REQUEST_FAILED` with `cause.name === "AbortError"`
|
||||
|
||||
**Verified by:** `src/communicator/http/http-communicator.test.ts::"sendTask resolves with typed data on success"` AND `src/communicator/http/http-communicator.test.ts::"rejects with HTTP.RESPONSE_MALFORMED when envelope shape is wrong"` AND `src/communicator/http/http-communicator.test.ts::"rejects with HTTP.PROTOCOL_ERROR and carries upstream message on cause"` AND `src/communicator/http/http-communicator.test.ts::"forwards AbortSignal to the injected fetch"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RemoteWorker — connect/reconnect via `@suaveplan/retry`
|
||||
|
||||
The package SHALL ship `RemoteWorker` extending `AbstractWorker` from `@suaveplan/queue-distribution`. Constructor accepts `endpoint`, `communicator: WorkerCommunicator`, `capacity`, `weight`, optional `id`, optional logger, and `RemoteWorkerOptions { healthCheckIntervalMs?, maxConnectionAttempts? }`. `doStart()` SHALL invoke `connect()` and start a periodic health-check loop on the injected `Clock`. `connect()` SHALL use `@suaveplan/retry` exponential-backoff between attempts (initial delay 1,000 ms, factor 2, max 10,000 ms, max attempts from `maxConnectionAttempts` default 3). The retry SHALL NOT recurse — it MUST use a bounded loop. `doStop()` SHALL stop the health-check loop and `disconnect` the communicator; disconnect errors SHALL be logged but NOT re-thrown. `doExecuteTask` SHALL throw `HTTP.CONNECT_FAILED` when `isConnected === false`. Errors during `communicator.sendTask` SHALL be routed through `handleCommunicationError(error)`: ping with a 5,000 ms timeout; if ping succeeds, the error propagates and the worker stays in current status; if ping fails, the worker is `markUnhealthy("Unreachable")`, `isConnected = false`, and a single non-recursive `attemptConnection()` is scheduled. `getHealth()` SHALL fetch real health from the remote worker; failures SHALL log a warning and return a default unhealthy payload. `getCapabilities()` SHALL return `{ isRemote: true, protocol, endpoint }`. `getType()` SHALL return `"remote"`.
|
||||
|
||||
#### Scenario: connect succeeds on first try
|
||||
|
||||
- **WHEN** `worker.start()` is called and `communicator.connect`/`checkStatus` resolve with `WorkerStatus.ONLINE`
|
||||
- **THEN** `worker.status === ONLINE` AND the connection-attempts counter is back at 0
|
||||
|
||||
#### Scenario: connect retries with exponential backoff and exhausts
|
||||
|
||||
- **WHEN** `communicator.connect` rejects on every call under `maxConnectionAttempts === 3` and the test injects a `FakeClock`
|
||||
- **THEN** `worker.start()` rejects with `QueueDistributionHttpError(HTTP.CONNECT_FAILED)` AND the `FakeClock` recorded sleeps of 1,000 ms then 2,000 ms (no recursion stack growth)
|
||||
|
||||
#### Scenario: unreachable remote marks worker UNHEALTHY
|
||||
|
||||
- **WHEN** `sendTask` throws and a subsequent ping rejects
|
||||
- **THEN** `worker.status === UNHEALTHY`, `isConnected === false`, AND a follow-up `attemptConnection` is scheduled (not awaited)
|
||||
|
||||
**Verified by:** `src/worker/remote/remote.test.ts::"start resolves with ONLINE on first connect"` AND `src/worker/remote/remote.test.ts::"exhausts retries and throws HTTP.CONNECT_FAILED under fake clock"` AND `src/worker/remote/remote.test.ts::"marks worker UNHEALTHY and schedules reconnect when ping fails"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable error registry for the HTTP plugin
|
||||
|
||||
The package SHALL register every error code through `defineCodes("HTTP", [...])` from `@suaveplan/error` and expose `QueueDistributionHttpError extends SuaveplanError<HttpErrorCode>`. The exhaustive set is: `REQUEST_FAILED`, `PROTOCOL_ERROR`, `TIMEOUT`, `RESPONSE_MALFORMED`, `CONNECT_FAILED`, `UNREACHABLE`. 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-http-error.test.ts::"every registered HTTP.* code is thrown by a production call path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL emit, via `@suaveplan/opentelemetry` under meter name `"@suaveplan/queue-distribution-http"`:
|
||||
|
||||
- histogram: `queue_dist_http.request.duration_ms` (attributes `endpoint`, `operation: "sendTask" | "checkStatus" | "checkHealth" | "ping"`, `outcome: "success" | "failure"`)
|
||||
- counter: `queue_dist_http.request_total` (same attributes)
|
||||
- counter: `queue_dist_http.connect.attempts_total` (attribute `outcome: "success" | "failure" | "exhausted"`)
|
||||
- span: `queue_dist_http.sendTask`, `queue_dist_http.connect`
|
||||
|
||||
#### Scenario: histogram emission
|
||||
|
||||
- **WHEN** `sendTask` resolves successfully under an in-memory `MeterProvider`
|
||||
- **THEN** one observation is recorded on `queue_dist_http.request.duration_ms` with `outcome === "success"` AND `operation === "sendTask"`
|
||||
|
||||
**Verified by:** `src/communicator/http/http-communicator.spec-conformance.test.ts::"emits queue_dist_http.request.duration_ms histogram with operation and outcome attributes"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user