202 lines
7.1 KiB
Markdown
202 lines
7.1 KiB
Markdown
# Design: `@suaveplan/web-workers`
|
|
|
|
Browser-tier Web Worker / SharedWorker / Worker-pool runtime, built on top of
|
|
`@suaveplan/events` (`EventBus`), `@suaveplan/error` (`SuaveplanError`,
|
|
`defineCodes`), `@suaveplan/opentelemetry` (counters / histograms), and
|
|
`@suaveplan/types` (`Clock`, `IdGenerator`).
|
|
|
|
## File layout
|
|
|
|
```text
|
|
packages/foundation/data/web-workers/
|
|
package.json
|
|
README.md
|
|
tsconfig.lib.json
|
|
vite.config.ts
|
|
vitest.config.ts
|
|
src/
|
|
index.ts
|
|
base-manager/
|
|
base-manager.ts # protected lifecycle + RPC state shared by classic + shared
|
|
base-manager.md
|
|
base-manager.test.ts
|
|
base-manager.types.ts
|
|
worker-manager/
|
|
worker-manager.ts # classic Worker manager
|
|
worker-manager.md
|
|
worker-manager.test.ts
|
|
shared-worker-manager/
|
|
shared-worker-manager.ts # SharedWorker manager (port-per-client)
|
|
shared-worker-manager.md
|
|
shared-worker-manager.test.ts
|
|
worker-pool/
|
|
worker-pool.ts # FIFO + round-robin + least-busy + restart-on-crash + health probe
|
|
worker-pool.md
|
|
worker-pool.test.ts
|
|
scheduler.ts
|
|
scheduler.md
|
|
scheduler.test.ts
|
|
message-bus/
|
|
message-bus.ts # in-process pub/sub + request/response
|
|
message-bus.md
|
|
message-bus.test.ts
|
|
streaming/
|
|
stream.ts # sendStream → AsyncIterable<T>
|
|
stream.md
|
|
stream.test.ts
|
|
circuit-breaker/
|
|
circuit-breaker.ts # open / half-open / closed, configurable thresholds
|
|
circuit-breaker.md
|
|
circuit-breaker.test.ts
|
|
helpers/
|
|
define-worker.ts # defineWorker({ url, type })
|
|
define-worker.md
|
|
define-worker.test.ts
|
|
observability/
|
|
observability.ts # createWebWorkersObservability
|
|
observability.md
|
|
observability.test.ts
|
|
observability.types.ts
|
|
errors/
|
|
errors.ts # WEB_WORKERS_CODES + WebWorkersError
|
|
errors.md
|
|
errors.test.ts
|
|
types/
|
|
web-workers.types.ts # public type definitions
|
|
web-workers.types.md
|
|
```
|
|
|
|
## Key design decisions
|
|
|
|
### `BaseWorkerManager` shares lifecycle + RPC state
|
|
|
|
Both `WorkerManager` (classic `Worker`) and `SharedWorkerManager` (SharedWorker
|
|
port) need:
|
|
|
|
- a named registry of managed instances,
|
|
- per-instance pending-request maps with timers + AbortSignal,
|
|
- circuit breaker state,
|
|
- backpressure ceiling on `maxPendingRequests`,
|
|
- `EventBus<WorkerManagerEventMap>` emission.
|
|
|
|
The base class owns all of that. The two concrete classes only differ in how
|
|
they instantiate the underlying transport (`new Worker` vs
|
|
`new SharedWorker(...).port`) and how `terminate` cleans up (`worker.terminate()`
|
|
vs `port.close()`).
|
|
|
|
### `EventBus` payload reshape
|
|
|
|
Upstream `WorkerManagerEventMap` uses variadic listener args
|
|
(`"worker:created": [name: string, config: WorkerConfig]`). The genesis
|
|
`@suaveplan/events` `EventBus` takes a single payload per listener. We reshape
|
|
every event into an object payload:
|
|
`{ name: string, config: WorkerConfig }`. Consumers gain a self-documenting
|
|
payload and we keep the contract testable.
|
|
|
|
### `WorkerPool` scheduling
|
|
|
|
- **Round-robin** (`scheduler: "round-robin"`, default): each `submit()` picks
|
|
`workers[next++ % workers.length]`. Cheap, fair, deterministic.
|
|
- **Least-busy** (`scheduler: "least-busy"`): pick the worker with the smallest
|
|
`pendingResponses.size`. Better for skewed task durations.
|
|
- **FIFO queue** when the pool is saturated (`maxConcurrent` reached): tasks
|
|
wait on a queue, drained as workers free up.
|
|
- **Backpressure**: queue has a `maxQueueDepth`; over-quota submits reject with
|
|
`WEB_WORKERS.QUEUE_FULL`.
|
|
- **Restart-on-crash**: when a managed worker emits `error`, the pool
|
|
terminates it, increments a restart counter, and re-creates a fresh one from
|
|
the same `WorkerConfig`. `maxRestarts` per worker bounds the loop; exceeding
|
|
it removes the slot and rejects in-flight requests with
|
|
`WEB_WORKERS.RESTART_EXHAUSTED`.
|
|
- **Health probe**: optional `healthCheck: (worker) => Promise<boolean>` runs
|
|
on an interval; failing workers are restarted.
|
|
|
|
### AbortSignal + transfer list + backpressure
|
|
|
|
`sendRequest` accepts an optional `{ signal?, transfer?, priority? }`. Aborts
|
|
reject with `WEB_WORKERS.REQUEST_ABORTED`; transfers go through the second
|
|
argument of `postMessage`. The pending-request map has a configurable ceiling
|
|
(`maxPendingRequests`, default 1024) — over-quota requests reject immediately
|
|
with `WEB_WORKERS.BACKPRESSURE`.
|
|
|
|
### Circuit breaker
|
|
|
|
Per-worker breaker tracks consecutive `WEB_WORKERS.REQUEST_TIMEOUT` /
|
|
`WEB_WORKERS.WORKER_ERROR` failures. After `failureThreshold` it opens for
|
|
`resetMs`; while open, `sendRequest` rejects with
|
|
`WEB_WORKERS.CIRCUIT_OPEN`. After cool-down it goes half-open: the next
|
|
request is allowed; success closes it, failure re-opens.
|
|
|
|
### Streaming RPC
|
|
|
|
`sendStream<TReq, TChunk>(name, type, payload)` returns
|
|
`AsyncIterable<TChunk>`. The worker pushes `{ type, payload: { chunk }, id, sequence }`
|
|
frames terminated by `{ type, payload: { done: true }, id }`. Cancellation:
|
|
`AsyncIterator.return()` cancels via `AbortSignal` and emits a final
|
|
`{ type: "stream:cancel", id }` to the worker.
|
|
|
|
### `defineWorker` helper
|
|
|
|
```ts
|
|
defineWorker({ url, type = "module" })
|
|
```
|
|
|
|
Returns a callable `WorkerFactory` that does
|
|
`new Worker(url, { type })`. The helper isolates the "must use
|
|
`new URL('./worker.ts', import.meta.url)`" pattern from consumer code and
|
|
makes the factory injectable into managers without re-typing the options
|
|
object.
|
|
|
|
### Error registry
|
|
|
|
```text
|
|
WEB_WORKERS.DUPLICATE_WORKER 409
|
|
WEB_WORKERS.WORKER_NOT_FOUND 404
|
|
WEB_WORKERS.WORKER_TERMINATED 410
|
|
WEB_WORKERS.REQUEST_TIMEOUT 504
|
|
WEB_WORKERS.REQUEST_ABORTED 499
|
|
WEB_WORKERS.WORKER_ERROR 500
|
|
WEB_WORKERS.BACKPRESSURE 429
|
|
WEB_WORKERS.QUEUE_FULL 429
|
|
WEB_WORKERS.CIRCUIT_OPEN 503
|
|
WEB_WORKERS.RESTART_EXHAUSTED 503
|
|
WEB_WORKERS.HANDLER_NOT_FOUND 404
|
|
WEB_WORKERS.STREAM_ABORTED 499
|
|
WEB_WORKERS.INVALID_TRANSFER 400
|
|
```
|
|
|
|
Each code is reachable from a real call site; every code is tested.
|
|
|
|
### OTel instruments
|
|
|
|
Counters:
|
|
|
|
- `web_workers_messages_sent_total`
|
|
- `web_workers_messages_received_total`
|
|
- `web_workers_workers_created_total`
|
|
- `web_workers_workers_terminated_total`
|
|
- `web_workers_restarts_total`
|
|
- `web_workers_errors_total`
|
|
|
|
Histograms:
|
|
|
|
- `web_workers_rpc_latency_ms`
|
|
- `web_workers_queue_depth`
|
|
- `web_workers_pool_active_workers`
|
|
|
|
Created via `@suaveplan/opentelemetry`'s `createCounter` / `createHistogram`,
|
|
identical pattern to `@suaveplan/clipboard`.
|
|
|
|
### Test strategy
|
|
|
|
- No real `Worker` API. We ship a `FakeWorker` / `FakeSharedWorker` test
|
|
helper inside `*.test.ts`. The injected `WorkerFactory` / `SharedWorkerFactory`
|
|
return fake instances that simulate message + error events via a
|
|
`MessageChannel` mirror. This is **not a mock** — it is a real
|
|
Web-Worker-protocol implementation in-process, which satisfies CLAUDE rule 2
|
|
(no `vi.mock` / `jest.mock`).
|
|
- Coverage: 100/100/100/100 enforced by `vitest.config.ts`.
|
|
- All `it()` carry `{ timeout: 5000 }` (async) / 30000 (long).
|
|
- Tests import runner primitives from `@suaveplan/testing/runner` per
|
|
CLAUDE rule 11.
|