21 KiB
Capability Spec Delta: queue-distribution (enterprise-queue-distribution)
Package: @suaveplan/queue-distribution
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: Distributed task contract decoupled from @suaveplan/queue.Job
The package SHALL expose DistributedTask<I, O> as the unit of work executed by workers, independent of @suaveplan/queue's Job / state-machine model. The contract has: id: string, execute(input: I, context: ExecutionContext): Promise<O>, getMetadata(): TaskMetadata, getStatus(): TaskStatus, setStatus(status: TaskStatus): void. TaskMetadata includes createdAt: Date, optional startedAt/completedAt, attempts: number, optional tags: string[], and indexed extras. TaskStatus is the closed enum PENDING | RUNNING | COMPLETED | FAILED | CANCELLED | TIMED_OUT. ExecutionContext is caller-defined.
Scenario: typed metadata round-trip
- WHEN a caller constructs a
DistributedTask<I, O>literal and passes it throughWorker.executeTask - THEN
task.getMetadata()returns a value validated byTaskMetadataSchemaandgetStatus()reflects the lifecycle (PENDING→RUNNING→COMPLETED/FAILED)
Scenario: generics are preserved end-to-end
- WHEN a caller writes
const w: Worker; const t: DistributedTask<number, string>; const r = await w.executeTask(t, 42) - THEN the inferred type of
ris exactlystring(nounknown, noany)
Verified by: src/task/task.test.ts::"validates TaskMetadata via TaskMetadataSchema" AND src/task/task.spec-conformance.test.ts::"preserves <I, O> generics through Worker.executeTask"
Requirement: Worker interface and status model
The package SHALL expose a Worker interface with: id: string, capacity: number, weight: number, status: WorkerStatus, activeTasks: number, executeTask<I, O>(task, input, signal?): Promise<O>, getHealth(signal?): Promise<WorkerHealth>, start(signal?): Promise<void>, stop(force?, signal?): Promise<void>, canAcceptTask(): boolean, getInfo(): WorkerInfo. WorkerStatus is the closed enum ONLINE | BUSY | OFFLINE | UNHEALTHY. WorkerHealth has isHealthy: boolean and a metrics object containing at least cpu, memory, tasksFailed, tasksSucceeded, responseTime plus open-ended string-keyed extras. WorkerInfo returns identity, type label, capacity, weight, status, active count, startedAt?, lastHealthCheck?, lastHealthStatus?, and capabilities?.
Scenario: health and info shapes parse via Zod
- WHEN a
Workerimplementation returns fromgetHealth()/getInfo() - THEN the value passes
WorkerHealthSchema/WorkerInfoSchema(failures throwQueueDistributionError(QUEUE_DIST.INVALID_OPTIONS))
Scenario: canAcceptTask honours status and capacity
- WHEN
status === ONLINEandactiveTasks < capacity - THEN
canAcceptTask()returnstrue - WHEN
status === OFFLINEoractiveTasks >= capacity - THEN
canAcceptTask()returnsfalse
Verified by: src/worker/worker.test.ts::"WorkerHealthSchema rejects malformed health" AND src/worker/worker.test.ts::"canAcceptTask honours ONLINE+capacity and rejects OFFLINE"
Requirement: AbstractWorker lifecycle and metrics, no module-scope clocks
The package SHALL ship an AbstractWorker base class implementing Worker with: idempotent start() (no-op when not OFFLINE) emitting WorkerStatus.ONLINE; stop(force) rejecting when activeTasks > 0 && !force with QUEUE_DIST.WORKER_BUSY_STOP; executeTask tracking activeTasks, success/failure counters, response-time total, and toggling status between ONLINE and BUSY; markUnhealthy(reason) flipping status to UNHEALTHY; getInfo() aggregating all of the above. The class SHALL accept Clock, IdGenerator, and LoggerLike via constructor options — no Date.now(), no Math.random(), no uuid import at module scope.
Scenario: executeTask refuses OFFLINE workers
- WHEN
await worker.executeTask(task, input)is called whilestatus === OFFLINE - THEN the call rejects with
QueueDistributionErrorwhose code isQUEUE_DIST.WORKER_NOT_AVAILABLE
Scenario: force stop cancels active tasks
- WHEN
await worker.stop(true)is called withactiveTasks === 2 - THEN
worker.status === OFFLINEANDworker.activeTasks === 0
Scenario: metrics emit through injectable Clock
- WHEN a
FakeClockreturns1000at task start and1750at completion - THEN
getHealth()returnsmetrics.responseTime === 750(the difference, not wall-clock)
Verified by: src/worker/abstract/abstract.test.ts::"executeTask refuses OFFLINE worker with WORKER_NOT_AVAILABLE" AND src/worker/abstract/abstract.test.ts::"force stop drains activeTasks and transitions to OFFLINE" AND src/worker/abstract/abstract.spec-conformance.test.ts::"metrics derive responseTime from injected Clock"
Requirement: LocalWorker in-process execution
The package SHALL ship LocalWorker<Ctx> extending AbstractWorker. doExecuteTask SHALL invoke task.execute(input, this.context) where context is the ExecutionContext passed at construction. getCapabilities() SHALL return { hasFileSystemAccess: true, isRemote: false, contextType: <ctor name> }. getType() SHALL return "local".
Scenario: task receives the configured context
- WHEN
new LocalWorker(ctx).executeTask(task, input)is called - THEN
task.execute(input, ctx)is invoked with the exact samectxreference
Verified by: src/worker/local/local.test.ts::"executeTask passes the configured ExecutionContext to task.execute"
Requirement: Load-balancing strategy interface
The package SHALL expose LoadBalancingStrategy with selectWorker(workers, task?): Worker | null, optional notifyWorkerSelected(worker, task), optional notifyTaskCompleted(worker, task, success, durationMs), optional getName(): string. AbstractLoadBalancingStrategy provides filterAvailableWorkers(workers): Worker[] returning workers with status === ONLINE || status === BUSY, activeTasks < capacity, and canAcceptTask() === true.
Scenario: filterAvailableWorkers excludes OFFLINE and at-capacity workers
- WHEN the input list contains workers in every
WorkerStatusstate and varyingactiveTasks - THEN the output contains only those satisfying the three predicates above
Verified by: src/load-balancing/base/base.test.ts::"filterAvailableWorkers returns only ONLINE/BUSY workers under capacity"
Requirement: Round-robin strategy
The package SHALL ship RoundRobinStrategy that selects workers in rotation order. currentIndex SHALL wrap to 0 when reaching the end of the available-worker list, and SHALL clamp to 0 if the available-worker list shrinks below currentIndex. reset() SHALL set currentIndex to 0. selectWorker SHALL return null when no workers are available. getName() SHALL return "round-robin".
Scenario: rotation order and reset
- WHEN
selectWorkeris invoked 4 times against the same 3-worker list - THEN the selections are workers
[0, 1, 2, 0]in order - WHEN
reset()is then called andselectWorkerinvoked once more - THEN the selection is worker
[0]again
Scenario: pool shrink is safe
- WHEN
currentIndex === 2and the available-worker list shrinks tolength === 1 - THEN the next
selectWorkerreturns worker[0](no out-of-range read)
Verified by: src/load-balancing/round-robin/round-robin.test.ts::"cycles through workers in order and wraps after the end" AND src/load-balancing/round-robin/round-robin.test.ts::"clamps currentIndex when the pool shrinks below it"
Requirement: Least-busy strategy with optional performance bias
The package SHALL ship LeastBusyStrategy selecting the worker with the fewest activeTasks. When usePerformanceMetrics is true, ties on activeTasks SHALL break by lower avgExecutionTime. Further ties SHALL break by higher capacity then higher weight. notifyTaskCompleted(worker, task, success, durationMs) SHALL update an internal EWMA-style cumulative average for successful runs only. getWorkerPerformance(id) SHALL return the stored record or null. resetPerformanceMetrics() SHALL clear the registry. setUsePerformanceMetrics(enabled) SHALL toggle the flag at runtime.
Scenario: tie-break by performance when enabled
- WHEN two workers both have
activeTasks === 0, perf metrics enabled, and recorded average durations of 100 ms and 200 ms - THEN
selectWorkerreturns the 100 ms worker
Scenario: tie-break by capacity when perf metrics unavailable
- WHEN two workers both have
activeTasks === 0and no perf history, withcapacity2 and 4 - THEN
selectWorkerreturns thecapacity === 4worker
Verified by: src/load-balancing/least-busy/least-busy.test.ts::"ties break by lower average execution time when usePerformanceMetrics is on" AND src/load-balancing/least-busy/least-busy.test.ts::"ties break by higher capacity when no performance history exists"
Requirement: Weighted strategy with injectable RNG
The package SHALL ship WeightedStrategy that selects workers with probability proportional to weight. The RNG SHALL be supplied via constructor option (Rng from @suaveplan/types); the strategy SHALL NOT call Math.random(). When totalWeight <= 0, the strategy SHALL fall back to uniform selection. getName() SHALL return "weighted".
Scenario: deterministic distribution under fixed RNG
- WHEN a deterministic
Rngyields the sequence[0.0, 0.5, 0.9]against weights[1, 3, 6](total 10) - THEN
selectWorkerreturns workers[0, 1, 2]in that order
Scenario: zero-weight pool falls back to uniform
- WHEN every worker has
weight === 0and theRngyields0.5 - THEN
selectWorkerreturns the middle worker in the list
Verified by: src/load-balancing/weighted/weighted.test.ts::"selects workers proportionally under a deterministic Rng" AND src/load-balancing/weighted/weighted.test.ts::"falls back to uniform when total weight is zero"
Requirement: Periodic health checker
The package SHALL ship WorkerHealthChecker (interface) and PeriodicHealthChecker (implementation). checkHealth(worker) SHALL race the worker's getHealth() against an injectable-Clock-driven timeout (default 5,000 ms). On success, results SHALL be cached for cacheTimeMs (default 5,000 ms) when cacheResults is true. Consecutive failures (unhealthyThreshold, default 3) SHALL flip worker.status to UNHEALTHY; consecutive successes (healthyThreshold, default 2) SHALL flip UNHEALTHY back to ONLINE or BUSY based on activeTasks. getHealthCheckCounts(id), resetHealthCheckCounts(id), and clearCache() SHALL be exposed.
Scenario: unhealthy after threshold consecutive failures
- WHEN
worker.getHealth()rejects on three consecutive calls (with threshold 3) - THEN
worker.status === UNHEALTHYafter the third call, ANDQUEUE_DIST.HEALTH_CHECK_FAILEDis emitted via the OTel counter
Scenario: recovery after threshold consecutive successes
- WHEN a worker previously marked UNHEALTHY returns healthy on two consecutive calls (with threshold 2) and has
activeTasks === 0 - THEN
worker.status === ONLINEafter the second call - WHEN the same but
activeTasks > 0 - THEN
worker.status === BUSY
Scenario: timeout uses injected Clock
- WHEN the test injects a
FakeClockandworker.getHealth()never resolves - THEN after advancing the clock by
healthCheckTimeoutMs,checkHealthreturns the fallbackisHealthy: falsepayload AND emits the OTelqueue_dist.health_check.duration_mshistogram with the elapsed time
Verified by: src/health-checker/health-checker.test.ts::"flips worker to UNHEALTHY after threshold consecutive failures" AND src/health-checker/health-checker.test.ts::"recovers worker to ONLINE/BUSY after threshold consecutive successes" AND src/health-checker/health-checker.spec-conformance.test.ts::"timeout race honours injected Clock and emits duration histogram"
Requirement: Worker pool registry and search
The package SHALL ship WorkerPool with: addWorker(worker) (throws QUEUE_DIST.WORKER_DUPLICATE on duplicate id), removeWorker(id), getWorkerById(id), getAllWorkers(), getWorkersByStatus(status), getAvailableWorkers(), findWorkers(criteria) filtering by status, capacity range, weight range, type, and a custom matcher predicate, startAllWorkers(signal?), stopAllWorkers(force?, signal?), clear(), size(), getWorkersInfo().
Scenario: duplicate add throws WORKER_DUPLICATE
- WHEN
addWorker(w)is called twice with the samew.id - THEN the second call throws
QueueDistributionError(QUEUE_DIST.WORKER_DUPLICATE)
Scenario: findWorkers matches every criteria field
- WHEN
findWorkers({ status: ONLINE, minCapacity: 2, maxWeight: 3, type: "local", matcher: w => w.activeTasks === 0 })is called against a mixed pool - THEN only workers satisfying every predicate are returned
Verified by: src/pool/pool.test.ts::"addWorker rejects duplicate ids with WORKER_DUPLICATE" AND src/pool/pool.test.ts::"findWorkers filters by every criteria field including custom matcher"
Requirement: WorkerManager — assignment, retry, health, restart
The package SHALL ship WorkerManager(pool, balancer?, healthChecker?, options?, deps?) providing:
assignTask<I, O>(task, input, signal?)— selects via the configuredLoadBalancingStrategy, executes on the chosen worker, retries up tomaxTaskRetries(default 3) on failure with backoff via@suaveplan/retry, and records exhausted tasks infailedTasks. The retry sleep SHALL be driven by the injectedClock, NOTsetTimeoutat module scope. The retry loop SHALL be bounded (no recursive self-call). AbortSignal cancellation MUST be honoured.assignTasks<I, O>(tasks[], inputs[], signal?)—Promise.alloverassignTask; mismatch in array lengths throwsQUEUE_DIST.INVALID_OPTIONS.startHealthChecks()/stopHealthChecks()— periodic loop driven by injectedClock.setInterval; idempotent.checkAllWorkersHealth(signal?)— fan-out over all workers, returns map of id →WorkerHealth; logs a warning when online count falls belowminOnlineWorkers.handleWorkerFailure(worker, error)— checks isHealthy; if not, flips toUNHEALTHYand optionally restarts.setLoadBalancer(balancer),getLoadBalancer(),getHealthChecker(),getWorkerPool(),getFailedTasks(),clearFailedTasks(),getTaskRetries(id).getOptions()/updateOptions(partial)— option changes that affect the health-check interval SHALL restart the loop.
The constructor SHALL throw QUEUE_DIST.INVALID_OPTIONS for negative maxTaskRetries, negative taskRetryDelayMs, or minOnlineWorkers < 0.
Scenario: no available workers throws NO_AVAILABLE_WORKERS
- WHEN
pool.getAvailableWorkers()returns[]andassignTaskis called - THEN the call rejects with
QueueDistributionError(QUEUE_DIST.NO_AVAILABLE_WORKERS)
Scenario: balancer returning null throws STRATEGY_NO_WORKER_SELECTED
- WHEN the configured
LoadBalancingStrategy.selectWorkerreturnsnulldespite a non-empty available pool - THEN the call rejects with
QueueDistributionError(QUEUE_DIST.STRATEGY_NO_WORKER_SELECTED)
Scenario: retries exhausted throws TASK_RETRIES_EXHAUSTED
- WHEN the chosen worker rejects
executeTaskmaxTaskRetries + 1times in a row - THEN the call rejects with
QueueDistributionError(QUEUE_DIST.TASK_RETRIES_EXHAUSTED)ANDgetFailedTasks()contains the task
Scenario: AbortSignal cancellation between retries
- WHEN an AbortController is aborted between the first failure and the retry sleep
- THEN the call rejects with
QueueDistributionError(QUEUE_DIST.TASK_CANCELLED)without sleeping the fulltaskRetryDelayMs
Scenario: restart limit exhaustion throws WORKER_RESTART_EXHAUSTED
- WHEN an unhealthy worker has reached
maxWorkerRestartsand the supervisor attempts another restart - THEN the supervisor emits
QUEUE_DIST.WORKER_RESTART_EXHAUSTEDvia the OTel counter and SHALL NOT callworker.start()again
Verified by: src/manager/manager.test.ts::"throws NO_AVAILABLE_WORKERS when pool is empty" AND src/manager/manager.test.ts::"throws STRATEGY_NO_WORKER_SELECTED when balancer returns null" AND src/manager/manager.test.ts::"throws TASK_RETRIES_EXHAUSTED and records the task in failedTasks" AND src/manager/manager.spec-conformance.test.ts::"AbortSignal cancellation between retries throws TASK_CANCELLED" AND src/manager/manager.spec-conformance.test.ts::"supervisor stops restarting after WORKER_RESTART_EXHAUSTED"
Requirement: Stable error registry via defineCodes
The package SHALL register every error code through defineCodes("QUEUE_DIST", [...]) from @suaveplan/error and expose QueueDistributionError extends SuaveplanError<QueueDistributionErrorCode>. The exhaustive code set is: WORKER_NOT_AVAILABLE, WORKER_DUPLICATE, WORKER_BUSY_STOP, WORKER_NOT_FOUND, WORKER_RESTART_EXHAUSTED, NO_AVAILABLE_WORKERS, STRATEGY_NO_WORKER_SELECTED, TASK_RETRIES_EXHAUSTED, HEALTH_CHECK_TIMEOUT, HEALTH_CHECK_FAILED, INVALID_OPTIONS, TASK_CANCELLED. Every code SHALL be thrown by at least one production call path.
Scenario: every registered code is thrown by a production code path
- WHEN the test suite iterates over every code in the registry
- THEN a corresponding throw site exists, and the test forces the failure and asserts the code matches
Verified by: src/errors/queue-distribution-error.test.ts::"every registered code is thrown by a production call path"
Requirement: OpenTelemetry instrumentation on every public entrypoint
The package SHALL emit the following instruments via @suaveplan/opentelemetry, registered under meter name "@suaveplan/queue-distribution":
- histograms:
queue_dist.task.duration_ms,queue_dist.health_check.duration_ms,queue_dist.worker.restart.duration_ms - counters:
queue_dist.task.assigned_total,queue_dist.task.completed_total(attributeoutcome: "success" | "failure"),queue_dist.task.retried_total,queue_dist.worker.restart_total(attributeoutcome: "success" | "failure" | "exhausted"),queue_dist.strategy.no_worker_total - up/down counter:
queue_dist.workers.active - spans:
queue_dist.manager.assign,queue_dist.worker.executeTask,queue_dist.health.check,queue_dist.worker.restart
Scenario: task duration histogram emits with expected attributes
- WHEN
manager.assignTask(task, input)completes successfully under an in-memoryMeterProvider - THEN the
queue_dist.task.duration_mshistogram has one recorded value with attributeoutcome === "success", AND thequeue_dist.task.completed_totalcounter incremented by 1 with the same attribute
Scenario: span emission
- WHEN
manager.assignTaskis invoked - THEN a span named
queue_dist.manager.assignis recorded with attributesqueue_dist.worker_idandqueue_dist.task_id
Verified by: src/manager/manager.spec-conformance.test.ts::"emits queue_dist.task.duration_ms and completed counter on success" AND src/manager/manager.spec-conformance.test.ts::"emits queue_dist.manager.assign span with worker_id and task_id attributes"
Requirement: Universal-tier — zero browser / Node / React deps
The package SHALL be installable in browser, Bun, and Node with no platform-specific runtime imports. The package SHALL NOT import from node:*, bun:*, react, react-dom, next/*, or any DOM-only API at module scope. sideEffects: false SHALL be declared in package.json and enforced via the tree-shake test.
Scenario: dep-graph guard rejects platform imports
- WHEN
tests/dep-graph.test.tsscans every.tsfile insrc/for forbidden imports - THEN the count of matches is zero AND the test passes
Verified by: tests/dep-graph.test.ts::"src/ contains no node:*, bun:*, or React imports"
Requirement: Test files use the canonical runner wrapper
Every test file in src/ SHALL import runner primitives (describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi, expectTypeOf) from @suaveplan/testing/runner, NOT from vitest or bun:test directly.
Scenario: scan shows zero direct runner imports
- WHEN
grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/flow/queue-distribution/src/ --include="*.ts" --include="*.tsx"is run - THEN the output is empty
Verified by: tests/dep-graph.test.ts::"no test file imports vitest or bun:test directly"
REMOVED Requirements
Requirement: WorkerManager.rebalance()
Reason: Upstream body was a stub: this.logger.warn("Rebalance operation not implemented"). Per CLAUDE.md §Rules ("Complete all implementations — no stubs") and AGENTS.md §2b ("NEVER ship stubs"), stubs do not ship to genesis.
Migration: None today — the method had no real implementation upstream, so no consumer can have relied on its behaviour. If a real rebalance algorithm is needed later, open a fresh change proposal.