Files
2026-05-27 23:29:53 +01:00

8.6 KiB

OpenSpec Change: enterprise-service-discovery

  • Change ID: enterprise-service-discovery
  • Package: @suaveplan/service-discovery
  • Category: universal
  • Target path: universal/network/service-discovery
  • Status: PROPOSED
  • Date: 2026-04-24
  • Reference spec: /mnt/s/web/suaveplan/enterprise/service-discovery.md

Summary

Deliver @suaveplan/service-discovery on the genesis branch from scratch at enterprise quality. The package provides a service registry and discovery system: ServiceRegistry (register/deregister/lookup services by name and tags), health-check scheduler (HTTP and TCP probes, injectable), TTL-based expiry, ServiceResolver with round-robin and weighted load balancing, in-memory and pluggable backends, and event-bus change notifications. Pure TypeScript, no platform hard deps.


Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data. The main-branch package had 97.76% coverage.


Available workspace deps on genesis

Package Role
@suaveplan/error (workspace:^) Base class for ServiceDiscoveryError; SERVICE_DISCOVERY.* codes
@suaveplan/types (workspace:^) LoggerLike, Clock, StorageLike
@suaveplan/events (workspace:^) Event bus for registry-change notifications
@suaveplan/network (workspace:^) HttpClient for HTTP health probes
@suaveplan/retry (workspace:^) Retry on health-check probes
zod (catalog:forms) Zod schemas for all public option types
@opentelemetry/api (catalog:observability) Spans + counters

Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing).


Public API

Core types

class ServiceDiscoveryError extends SuaveplanError

type ServiceStatus = 'healthy' | 'unhealthy' | 'unknown'
type HealthCheckType = 'http' | 'tcp' | 'custom'
type LoadBalancingStrategy = 'round-robin' | 'weighted' | 'random' | 'first-healthy'

interface ServiceEndpoint {
  host: string
  port: number
  protocol?: string           // default: 'http'
  path?: string               // default: '/'
}

interface HealthCheck {
  type: HealthCheckType
  intervalMs?: number         // default: 30000
  timeoutMs?: number          // default: 5000
  failureThreshold?: number   // default: 3; mark unhealthy after N consecutive failures
  successThreshold?: number   // default: 1; mark healthy after N consecutive successes
  customCheck?: (endpoint: ServiceEndpoint) => Promise<boolean>
}

interface ServiceRegistration {
  id: string                  // unique per instance
  name: string                // service name (logical)
  version?: string
  endpoint: ServiceEndpoint
  tags?: string[]
  metadata?: Record<string, string>
  ttlMs?: number              // auto-expire if not refreshed; 0 = no expiry
  healthCheck?: HealthCheck
  weight?: number             // for weighted load balancing; default: 1
}

interface ServiceInstance extends ServiceRegistration {
  status: ServiceStatus
  registeredAt: number        // unix epoch ms
  lastSeenAt: number
  lastCheckedAt?: number
  failureCount: number
  successCount: number
}

interface RegistryBackend {
  register(instance: ServiceRegistration): Promise<void>
  deregister(id: string): Promise<void>
  get(id: string): Promise<ServiceInstance | undefined>
  list(name?: string, tags?: string[]): Promise<ServiceInstance[]>
  refresh(id: string): Promise<void>             // reset TTL
  updateStatus(id: string, status: ServiceStatus): Promise<void>
}

interface ServiceRegistryOptions {
  backend?: RegistryBackend    // default: MemoryRegistryBackend
  events?: EventBusLike        // for change notifications
  clock?: Clock
  logger?: LoggerLike
}

class ServiceRegistry {
  constructor(options?: ServiceRegistryOptions)
  register(registration: ServiceRegistration): Promise<void>
  deregister(id: string): Promise<void>
  refresh(id: string): Promise<void>
  get(id: string): Promise<ServiceInstance | undefined>
  list(name?: string, tags?: string[]): Promise<ServiceInstance[]>
  listHealthy(name: string, tags?: string[]): Promise<ServiceInstance[]>
  startHealthChecks(client?: HttpClient): void
  stopHealthChecks(): void
}

interface ServiceResolverOptions {
  registry: ServiceRegistry
  strategy?: LoadBalancingStrategy     // default: 'round-robin'
  clock?: Clock
  logger?: LoggerLike
}

class ServiceResolver {
  constructor(options: ServiceResolverOptions)
  resolve(name: string, tags?: string[]): Promise<ServiceInstance>
  resolveAll(name: string, tags?: string[]): Promise<ServiceInstance[]>
}

function createServiceRegistry(options?: ServiceRegistryOptions): ServiceRegistry
function createServiceResolver(options: ServiceResolverOptions): ServiceResolver

// In-memory backend (default)
class MemoryRegistryBackend implements RegistryBackend { ... }

Error codes

type ServiceDiscoveryErrorCode =
  | 'SERVICE_DISCOVERY_NOT_FOUND'
  | 'SERVICE_DISCOVERY_ALREADY_REGISTERED'
  | 'SERVICE_DISCOVERY_HEALTH_CHECK_FAILED'
  | 'SERVICE_DISCOVERY_NO_HEALTHY_INSTANCES'
  | 'SERVICE_DISCOVERY_TTL_EXPIRED'
  | 'SERVICE_DISCOVERY_INVALID_REGISTRATION'

const SERVICE_DISCOVERY_ERROR_METADATA: Readonly<Record<ServiceDiscoveryErrorCode, CodeMetadata>>
function registerServiceDiscoveryErrorCodes(): void

Zod schemas

ServiceEndpointSchema
HealthCheckSchema
ServiceRegistrationSchema
ServiceInstanceSchema
ServiceRegistryOptionsSchema
ServiceResolverOptionsSchema

New additions for genesis

  1. Pluggable RegistryBackend — MemoryRegistryBackend ships by default; callers can swap to Redis, SurrealDB, or any other store implementing the interface.
  2. Health-check scheduler — HTTP probes use @suaveplan/network HttpClient; TCP probes use injected net.connect-compatible function. All timers use injectable Clock.
  3. TTL expiry — background sweeper removes expired registrations; sweep interval = min(TTL) / 2.
  4. Event notifications — 'service:registered', 'service:deregistered', 'service:status-changed' events emitted on EventBusLike.
  5. Load balancing — four strategies: round-robin (stateful cursor), weighted (WRR), random (injected RNG), first-healthy (deterministic).
  6. AbortSignal propagation — resolve accepts optional signal; throws ServiceDiscoveryError(SERVICE_DISCOVERY_NO_HEALTHY_INSTANCES) on abort.
  7. OTel instrumentation — counter service_discovery.register_total, counter service_discovery.resolve_total, histogram service_discovery.resolve_duration_ms, counter service_discovery.health_check_total (attr service_discovery.result).
  8. Property-based tests — round-robin resolves to all registered healthy instances in N calls (no skips), weighted strategy probability distribution converges, register+deregister+list invariants.
  9. Benchmark suite — register 100 services, resolve hot path (round-robin, 1000 instances), health check scheduler overhead.

File layout

universal/network/service-discovery/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts
    error-codes.ts
    constants/
      index.ts
    types/
      common.ts
      common.md
    core/
      service-registry/
        service-registry.ts
        service-registry.md
        service-registry.types.ts
        service-registry.test.ts
        index.ts
      service-resolver/
        service-resolver.ts
        service-resolver.md
        service-resolver.types.ts
        service-resolver.test.ts
        index.ts
    backends/
      memory-backend/
        memory-backend.ts
        memory-backend.md
        memory-backend.test.ts
        index.ts
    health/
      http-probe/
        http-probe.ts
        http-probe.md
        http-probe.test.ts
        index.ts
      tcp-probe/
        tcp-probe.ts
        tcp-probe.md
        tcp-probe.test.ts
        index.ts
      health-scheduler/
        health-scheduler.ts
        health-scheduler.md
        health-scheduler.test.ts
        index.ts
    balancing/
      round-robin/
        round-robin.ts
        round-robin.md
        round-robin.test.ts
        index.ts
      weighted/
        weighted.ts
        weighted.md
        weighted.test.ts
        index.ts
    __benchmarks__/
      service-discovery.bench.ts
  tests/
    integration.test.ts
    property.test.ts

Impact

Downstream: any microservice or multi-peer architecture on genesis; @suaveplan/api-gateway consumers. Breaking changes: none — fresh build. Prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/events, @suaveplan/network, @suaveplan/retry published to Verdaccio.