Initial Commit
This commit is contained in:
95
packages/networking/network/service-discovery/README.md
Normal file
95
packages/networking/network/service-discovery/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# @suaveplan/service-discovery - Universal service discovery primitives: ServiceRegistry with TTL/expiry, query, AbortSignal-aware watch, optional storage persistence and EventBusLike integration. Shared foundation for every network-discovery-* plugin.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal service-registry primitives. Provides the shared
|
||||
`ServiceDescriptor`, `ServiceQuery`, `RegistryEvent`, and `ServiceRegistry`
|
||||
contract consumed by every Wave-2 `network-discovery-*` plugin.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/service-discovery
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
```ts
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
|
||||
const registry = new ServiceRegistry({ ttlMs: 30_000 });
|
||||
|
||||
registry.register({
|
||||
id: "api-1",
|
||||
name: "api",
|
||||
address: "10.0.0.1:8080",
|
||||
status: "healthy",
|
||||
region: "us-east-1",
|
||||
tags: ["v1"],
|
||||
});
|
||||
|
||||
const healthy = registry.query({ name: "api", status: "healthy" });
|
||||
```
|
||||
|
||||
## Advanced usage: watch + bus + persistence
|
||||
|
||||
```ts
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
declare const bus: EventBusLike<{
|
||||
"service:registered": import("@suaveplan/service-discovery").ServiceDescriptor;
|
||||
"service:deregistered": import("@suaveplan/service-discovery").ServiceDescriptor;
|
||||
"service:expired": import("@suaveplan/service-discovery").ServiceDescriptor;
|
||||
}>;
|
||||
|
||||
declare const storage: import("@suaveplan/service-discovery").ServiceRegistryStorageLike;
|
||||
|
||||
const registry = new ServiceRegistry({
|
||||
ttlMs: 30_000,
|
||||
maxServices: 5_000,
|
||||
bus,
|
||||
storage,
|
||||
persistKey: "service-registry:v1",
|
||||
});
|
||||
|
||||
await registry.load();
|
||||
|
||||
const ac = new AbortController();
|
||||
(async () => {
|
||||
for await (const ev of registry.watch({ name: "api" }, ac.signal)) {
|
||||
console.log(ev.type, ev.service.id);
|
||||
}
|
||||
})();
|
||||
|
||||
// later:
|
||||
await registry.flush();
|
||||
ac.abort();
|
||||
registry.dispose();
|
||||
```
|
||||
|
||||
## What ships
|
||||
|
||||
- `ServiceRegistry` — in-memory store with TTL expiry, query, AbortSignal-
|
||||
aware `watch()` async iterator, optional persistent backing, optional
|
||||
`EventBusLike` fan-out.
|
||||
- Typed error class `SvcDiscError` + frozen `SVC_DISC_CODES` registry
|
||||
(`DUPLICATE_REGISTRATION` / `QUERY_INVALID` / `REGISTRY_FULL` /
|
||||
`INVALID_OPTIONS`).
|
||||
- Zod schemas at every public boundary.
|
||||
- OTel: `svc_disc.registrations_total{outcome}`, `svc_disc.queries_total`,
|
||||
`svc_disc.query_duration_ms`, `svc_disc.active_services` gauge.
|
||||
|
||||
See each feature module's sibling `.md` for the full surface.
|
||||
57
packages/networking/network/service-discovery/package.json
Normal file
57
packages/networking/network/service-discovery/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@suaveplan/service-discovery",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal service discovery primitives: ServiceRegistry with TTL/expiry, query, AbortSignal-aware watch, optional storage persistence and EventBusLike integration. Shared foundation for every network-discovery-* plugin.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./types": {
|
||||
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
|
||||
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
|
||||
},
|
||||
"./service-registry": {
|
||||
"import": { "types": "./dist/core/service-registry/service-registry.d.ts", "default": "./dist/core/service-registry/service-registry.js" },
|
||||
"require": { "types": "./dist/core/service-registry/service-registry.d.ts", "default": "./dist/core/service-registry/service-registry.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type RegisterInput,
|
||||
ServiceRegistry,
|
||||
} from "./service-registry.js";
|
||||
@@ -0,0 +1,118 @@
|
||||
# ServiceRegistry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`ServiceRegistry` is the universal in-memory store + watch surface that
|
||||
backs every `network-discovery-*` plugin (Wave 2). It models service
|
||||
registration, query, AbortSignal-aware change streaming, TTL-based expiry,
|
||||
optional persistent backing, and optional event-bus fan-out — all behind a
|
||||
single class with Zod-validated boundaries, OTel instrumentation, and
|
||||
typed `SvcDiscError` failures.
|
||||
|
||||
The registry is intentionally lightweight: no load balancing, no health
|
||||
checks, no DHT — those concerns live in higher-tier plugins and consume
|
||||
this primitive through its `query()` / `watch()` surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Upsert `register()` with optional `strict: true` mode raising
|
||||
`DUPLICATE_REGISTRATION`.
|
||||
- `refresh(id)` updates `lastSeen` only.
|
||||
- `deregister(id)` and `dispose()` for full teardown.
|
||||
- `query(filter)` snapshot returning matching descriptors.
|
||||
- `watch(filter, signal?)` async iterator pushing `registered` /
|
||||
`deregistered` / `expired` events; terminates cleanly on abort or
|
||||
`dispose()`.
|
||||
- TTL expiry on every read; `prune()` for explicit sweeps. Pruned
|
||||
entries emit `expired`.
|
||||
- Capacity guard at `maxServices` (default 10 000) raising `REGISTRY_FULL`.
|
||||
- Optional `storage` + `persistKey` snapshot via `flush()` / `load()`.
|
||||
- Optional `bus` emitting `service:registered` / `:deregistered` /
|
||||
`:expired` topics.
|
||||
- OTel: `svc_disc.registrations_total{outcome}`, `svc_disc.queries_total`,
|
||||
`svc_disc.query_duration_ms`, `svc_disc.active_services` gauge.
|
||||
- Injectable `Clock` from `@suaveplan/types` (no module-scope `Date.now()`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
|
||||
const registry = new ServiceRegistry({ ttlMs: 30_000 });
|
||||
|
||||
registry.register({
|
||||
id: "api-1",
|
||||
name: "api",
|
||||
address: "10.0.0.1:8080",
|
||||
status: "healthy",
|
||||
tags: ["v1"],
|
||||
});
|
||||
|
||||
const healthy = registry.query({ name: "api", status: "healthy" });
|
||||
console.log(healthy.length); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
|
||||
const registry = new ServiceRegistry({
|
||||
ttlMs: 5_000,
|
||||
maxServices: 1_000,
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
(async () => {
|
||||
for await (const ev of registry.watch({ name: "api" }, ac.signal)) {
|
||||
if (ev.type === "expired") console.warn("api instance gone:", ev.service.id);
|
||||
}
|
||||
})();
|
||||
|
||||
// later, on shutdown:
|
||||
ac.abort();
|
||||
registry.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ServiceRegistry(config?: ServiceRegistryConfig)` — validates via
|
||||
`ServiceRegistryConfigSchema`, throws `SvcDiscError(INVALID_OPTIONS)` on
|
||||
malformed input.
|
||||
- `register(input, options?)` — upsert by default; pass `{ strict: true }` to
|
||||
reject duplicates. Returns the stored `ServiceDescriptor`.
|
||||
- `refresh(id)` — touches `lastSeen` only; returns the new descriptor or
|
||||
`undefined` if unknown / expired.
|
||||
- `deregister(id)` — removes the entry; emits `deregistered`.
|
||||
- `get(id)`, `list()`, `size` — readers (auto-prune).
|
||||
- `query(filter)` — snapshot filter; throws `QUERY_INVALID` on bad shape.
|
||||
- `watch(filter, signal?)` — async iterator of `RegistryEvent`; throws
|
||||
`QUERY_INVALID` on bad shape.
|
||||
- `prune()` — explicit TTL sweep; returns the count removed.
|
||||
- `flush(signal?)` / `load(signal?)` — JSON snapshot through the configured
|
||||
`storage`.
|
||||
- `dispose()` — terminates all watchers, unbinds the OTel gauge.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The watch iterator is a hand-rolled async producer (not an async generator)
|
||||
so it can deliver an event to a pending `await iter.next()` consumer in
|
||||
constant time without scheduling through an additional microtask. Events
|
||||
that arrive while no consumer is awaiting are buffered in an in-order
|
||||
FIFO and flushed on the next `next()` call. The iterator implements
|
||||
`return()` so a `for await ... break;` cleanly removes the watcher slot
|
||||
and detaches the AbortSignal listener.
|
||||
|
||||
TTL pruning is intentionally lazy and runs on every reader. This keeps
|
||||
the registry constant-time-bounded by query rate rather than entry count,
|
||||
and means a registry with no traffic costs nothing in the background.
|
||||
Explicit `prune()` is offered for callers that need a deterministic sweep
|
||||
(e.g. before snapshotting).
|
||||
|
||||
Bus integration uses the structural `EventBusLike<TMap>` type from
|
||||
`@suaveplan/types`. The registry does not depend on `@suaveplan/events`
|
||||
or `@suaveplan/network`, so Wave-2 plugins can wire any bus implementation
|
||||
without inducing a cycle.
|
||||
@@ -0,0 +1,599 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { SVC_DISC_CODES, SvcDiscError } from "../../errors/errors.js";
|
||||
import type {
|
||||
RegistryEvent,
|
||||
ServiceDescriptor,
|
||||
ServiceRegistryEventMap,
|
||||
ServiceRegistryStorageLike,
|
||||
} from "../../types/types.js";
|
||||
import { ServiceRegistry } from "./service-registry.js";
|
||||
|
||||
class FakeClock {
|
||||
public t = 1_000;
|
||||
public readonly clock = (): number => this.t;
|
||||
public advance(ms: number): void {
|
||||
this.t += ms;
|
||||
}
|
||||
}
|
||||
|
||||
class MemStorage implements ServiceRegistryStorageLike {
|
||||
public readonly map = new Map<string, Uint8Array>();
|
||||
async get(key: string): Promise<Uint8Array | undefined> {
|
||||
return this.map.get(key);
|
||||
}
|
||||
async set(key: string, value: Uint8Array): Promise<void> {
|
||||
this.map.set(key, value);
|
||||
}
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return this.map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
class CapturingBus {
|
||||
public readonly events: Array<{
|
||||
topic: keyof ServiceRegistryEventMap;
|
||||
payload: ServiceDescriptor;
|
||||
}> = [];
|
||||
on(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
emit<K extends keyof ServiceRegistryEventMap>(
|
||||
topic: K,
|
||||
payload: ServiceRegistryEventMap[K]
|
||||
): void {
|
||||
this.events.push({ topic, payload });
|
||||
}
|
||||
}
|
||||
|
||||
const baseInput = {
|
||||
id: "a1",
|
||||
name: "api",
|
||||
address: "10.0.0.1:8080",
|
||||
status: "healthy" as const,
|
||||
};
|
||||
|
||||
describe("ServiceRegistry", () => {
|
||||
it("constructs with defaults", () => {
|
||||
const r = new ServiceRegistry();
|
||||
expect(r.size).toBe(0);
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
it("rejects malformed config", () => {
|
||||
expect(() => new ServiceRegistry({ maxServices: -1 } as never)).toThrow(
|
||||
SvcDiscError
|
||||
);
|
||||
try {
|
||||
new ServiceRegistry({ ttlMs: 0 } as never);
|
||||
} catch (err) {
|
||||
expect((err as SvcDiscError).code).toBe(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers a new service", () => {
|
||||
const clock = new FakeClock();
|
||||
const r = new ServiceRegistry({ clock: clock.clock });
|
||||
const out = r.register(baseInput);
|
||||
expect(out.id).toBe("a1");
|
||||
expect(out.registeredAt).toBe(1_000);
|
||||
expect(out.lastSeen).toBe(1_000);
|
||||
expect(r.size).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects invalid descriptor input", () => {
|
||||
const r = new ServiceRegistry();
|
||||
expect(() =>
|
||||
r.register({ id: "", name: "x", address: "y" } as never)
|
||||
).toThrow(SvcDiscError);
|
||||
});
|
||||
|
||||
it("upserts on duplicate by default (refreshed)", () => {
|
||||
const clock = new FakeClock();
|
||||
const r = new ServiceRegistry({ clock: clock.clock });
|
||||
const first = r.register(baseInput);
|
||||
clock.advance(5_000);
|
||||
const second = r.register({
|
||||
...baseInput,
|
||||
address: "10.0.0.2:9090",
|
||||
});
|
||||
expect(second.registeredAt).toBe(first.registeredAt);
|
||||
expect(second.lastSeen).toBe(6_000);
|
||||
expect(second.address).toBe("10.0.0.2:9090");
|
||||
});
|
||||
|
||||
it("strict mode rejects duplicates", () => {
|
||||
const r = new ServiceRegistry();
|
||||
r.register(baseInput);
|
||||
try {
|
||||
r.register(baseInput, { strict: true });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SvcDiscError).code).toBe(
|
||||
SVC_DISC_CODES.DUPLICATE_REGISTRATION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises REGISTRY_FULL at capacity", () => {
|
||||
const r = new ServiceRegistry({ maxServices: 2 });
|
||||
r.register({ ...baseInput, id: "1" });
|
||||
r.register({ ...baseInput, id: "2" });
|
||||
try {
|
||||
r.register({ ...baseInput, id: "3" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SvcDiscError).code).toBe(
|
||||
SVC_DISC_CODES.REGISTRY_FULL
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes lastSeen", () => {
|
||||
const clock = new FakeClock();
|
||||
const r = new ServiceRegistry({ clock: clock.clock });
|
||||
r.register(baseInput);
|
||||
clock.advance(2_000);
|
||||
const refreshed = r.refresh("a1");
|
||||
expect(refreshed?.lastSeen).toBe(3_000);
|
||||
});
|
||||
|
||||
it("refresh returns undefined for unknown id", () => {
|
||||
const r = new ServiceRegistry();
|
||||
expect(r.refresh("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deregister removes", () => {
|
||||
const r = new ServiceRegistry();
|
||||
r.register(baseInput);
|
||||
const removed = r.deregister("a1");
|
||||
expect(removed?.id).toBe("a1");
|
||||
expect(r.deregister("a1")).toBeUndefined();
|
||||
expect(r.size).toBe(0);
|
||||
});
|
||||
|
||||
it("get returns and respects TTL", () => {
|
||||
const clock = new FakeClock();
|
||||
const r = new ServiceRegistry({ ttlMs: 1_000, clock: clock.clock });
|
||||
r.register(baseInput);
|
||||
expect(r.get("a1")?.id).toBe("a1");
|
||||
clock.advance(2_000);
|
||||
expect(r.get("a1")).toBeUndefined();
|
||||
expect(r.get("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("list returns snapshot", () => {
|
||||
const r = new ServiceRegistry();
|
||||
r.register({ ...baseInput, id: "1" });
|
||||
r.register({ ...baseInput, id: "2" });
|
||||
expect(
|
||||
r
|
||||
.list()
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("query filters by every dimension", () => {
|
||||
const r = new ServiceRegistry();
|
||||
r.register({
|
||||
...baseInput,
|
||||
id: "1",
|
||||
region: "us",
|
||||
tags: ["v1", "edge"],
|
||||
});
|
||||
r.register({
|
||||
...baseInput,
|
||||
id: "2",
|
||||
region: "eu",
|
||||
tags: ["v1"],
|
||||
status: "unhealthy",
|
||||
});
|
||||
r.register({ ...baseInput, id: "3", name: "worker", region: "us" });
|
||||
|
||||
expect(r.query().length).toBe(3);
|
||||
expect(
|
||||
r
|
||||
.query({ name: "api" })
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
).toEqual(["1", "2"]);
|
||||
expect(
|
||||
r
|
||||
.query({ status: "healthy" })
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
).toEqual(["1", "3"]);
|
||||
expect(
|
||||
r
|
||||
.query({ region: "us" })
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
).toEqual(["1", "3"]);
|
||||
expect(r.query({ tags: ["v1", "edge"] }).map((s) => s.id)).toEqual([
|
||||
"1",
|
||||
]);
|
||||
expect(
|
||||
r
|
||||
.query({ tags: ["v1"] })
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
).toEqual(["1", "2"]);
|
||||
expect(r.query({ tags: ["missing"] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("query rejects bad shape", () => {
|
||||
const r = new ServiceRegistry();
|
||||
try {
|
||||
r.query({ name: "" } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SvcDiscError).code).toBe(
|
||||
SVC_DISC_CODES.QUERY_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("prune removes expired entries and reports count", () => {
|
||||
const clock = new FakeClock();
|
||||
const r = new ServiceRegistry({ ttlMs: 1_000, clock: clock.clock });
|
||||
r.register({ ...baseInput, id: "1" });
|
||||
r.register({ ...baseInput, id: "2" });
|
||||
clock.advance(2_000);
|
||||
expect(r.prune()).toBe(2);
|
||||
expect(r.size).toBe(0);
|
||||
});
|
||||
|
||||
it("emits to a configured bus", () => {
|
||||
const bus = new CapturingBus();
|
||||
const clock = new FakeClock();
|
||||
const r = new ServiceRegistry({
|
||||
clock: clock.clock,
|
||||
ttlMs: 1_000,
|
||||
bus,
|
||||
});
|
||||
r.register(baseInput);
|
||||
r.register(baseInput); // refresh -> still 'registered'
|
||||
r.deregister("a1");
|
||||
r.register({ ...baseInput, id: "exp" });
|
||||
clock.advance(2_000);
|
||||
r.prune();
|
||||
|
||||
const topics = bus.events.map((e) => e.topic);
|
||||
expect(topics).toEqual([
|
||||
"service:registered",
|
||||
"service:registered",
|
||||
"service:deregistered",
|
||||
"service:registered",
|
||||
"service:expired",
|
||||
]);
|
||||
});
|
||||
|
||||
it("watch yields events that match the query", async () => {
|
||||
const r = new ServiceRegistry();
|
||||
const events: RegistryEvent[] = [];
|
||||
const iter = r.watch({ name: "api" });
|
||||
const consumer = (async () => {
|
||||
for await (const ev of iter) {
|
||||
events.push(ev);
|
||||
if (events.length === 2) break;
|
||||
}
|
||||
})();
|
||||
|
||||
// Allow microtask boundary so the consumer attaches.
|
||||
await Promise.resolve();
|
||||
r.register(baseInput);
|
||||
r.register({ ...baseInput, id: "other", name: "worker" }); // filtered out
|
||||
r.deregister("a1");
|
||||
await consumer;
|
||||
|
||||
expect(events.map((e) => e.type)).toEqual([
|
||||
"registered",
|
||||
"deregistered",
|
||||
]);
|
||||
});
|
||||
|
||||
it("watch buffers events before consumer attaches", async () => {
|
||||
const r = new ServiceRegistry();
|
||||
const iter = r.watch();
|
||||
r.register(baseInput);
|
||||
r.register({ ...baseInput, id: "b" });
|
||||
const first = await iter.next();
|
||||
const second = await iter.next();
|
||||
expect(first.value?.type).toBe("registered");
|
||||
expect(second.value?.type).toBe("registered");
|
||||
await iter.return?.();
|
||||
});
|
||||
|
||||
it("watch terminates on AbortSignal abort", async () => {
|
||||
const r = new ServiceRegistry();
|
||||
const ac = new AbortController();
|
||||
const iter = r.watch({}, ac.signal);
|
||||
const result = iter.next();
|
||||
ac.abort();
|
||||
const step = await result;
|
||||
expect(step.done).toBe(true);
|
||||
});
|
||||
|
||||
it("watch with already-aborted signal yields done immediately", async () => {
|
||||
const r = new ServiceRegistry();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const iter = r.watch({}, ac.signal);
|
||||
const step = await iter.next();
|
||||
expect(step.done).toBe(true);
|
||||
});
|
||||
|
||||
it("watch.return cleans up watcher and detaches signal", async () => {
|
||||
const r = new ServiceRegistry();
|
||||
const ac = new AbortController();
|
||||
const iter = r.watch({}, ac.signal);
|
||||
const ret = await iter.return?.();
|
||||
expect(ret?.done).toBe(true);
|
||||
// Subsequent next yields done.
|
||||
const step = await iter.next();
|
||||
expect(step.done).toBe(true);
|
||||
// Calling return again must be safe (already-closed guard).
|
||||
const again = await iter.return?.();
|
||||
expect(again?.done).toBe(true);
|
||||
});
|
||||
|
||||
it("dispose after return is a no-op for the watcher", async () => {
|
||||
const r = new ServiceRegistry();
|
||||
const iter = r.watch();
|
||||
await iter.return?.();
|
||||
// Disposing the registry must still close cleanly even if the
|
||||
// watcher was already closed via return().
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
it("gaugeSize reports raw map size for OTel pull", () => {
|
||||
const r = new ServiceRegistry();
|
||||
expect(r.gaugeSize()).toBe(0);
|
||||
r.register(baseInput);
|
||||
expect(r.gaugeSize()).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves version, region, tags, and metadata on register", () => {
|
||||
const r = new ServiceRegistry();
|
||||
const out = r.register({
|
||||
...baseInput,
|
||||
version: "1.2.3",
|
||||
region: "us-east-1",
|
||||
tags: ["edge", "v1"],
|
||||
metadata: { weight: 5 },
|
||||
});
|
||||
expect(out.version).toBe("1.2.3");
|
||||
expect(out.region).toBe("us-east-1");
|
||||
expect(out.tags).toEqual(["edge", "v1"]);
|
||||
expect(out.metadata).toEqual({ weight: 5 });
|
||||
});
|
||||
|
||||
it("watch terminates when registry is disposed", async () => {
|
||||
const r = new ServiceRegistry();
|
||||
const iter = r.watch();
|
||||
const pending = iter.next();
|
||||
r.dispose();
|
||||
const step = await pending;
|
||||
expect(step.done).toBe(true);
|
||||
});
|
||||
|
||||
it("watch rejects invalid query", () => {
|
||||
const r = new ServiceRegistry();
|
||||
try {
|
||||
r.watch({ status: "weird" } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SvcDiscError).code).toBe(
|
||||
SVC_DISC_CODES.QUERY_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("expired events fire when reads trigger pruning", async () => {
|
||||
const clock = new FakeClock();
|
||||
const r = new ServiceRegistry({ ttlMs: 1_000, clock: clock.clock });
|
||||
const seen: string[] = [];
|
||||
const iter = r.watch();
|
||||
(async () => {
|
||||
for await (const ev of iter) {
|
||||
seen.push(ev.type);
|
||||
if (seen.length === 2) break;
|
||||
}
|
||||
})();
|
||||
await Promise.resolve();
|
||||
r.register(baseInput);
|
||||
clock.advance(2_000);
|
||||
r.list(); // triggers prune
|
||||
// give the consumer a tick to drain
|
||||
await new Promise((res) => setTimeout(res, 5));
|
||||
expect(seen).toContain("expired");
|
||||
});
|
||||
|
||||
it("register on disposed registry throws", () => {
|
||||
const r = new ServiceRegistry();
|
||||
r.dispose();
|
||||
try {
|
||||
r.register(baseInput);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SvcDiscError).code).toBe(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("double dispose is a no-op", () => {
|
||||
const r = new ServiceRegistry();
|
||||
r.dispose();
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
it("flush + load persist a snapshot", async () => {
|
||||
const storage = new MemStorage();
|
||||
const r1 = new ServiceRegistry({
|
||||
storage,
|
||||
persistKey: "registry:test",
|
||||
});
|
||||
r1.register(baseInput);
|
||||
r1.register({ ...baseInput, id: "b" });
|
||||
await r1.flush();
|
||||
|
||||
const r2 = new ServiceRegistry({
|
||||
storage,
|
||||
persistKey: "registry:test",
|
||||
});
|
||||
await r2.load();
|
||||
expect(r2.size).toBe(2);
|
||||
expect(r2.get("a1")?.id).toBe("a1");
|
||||
});
|
||||
|
||||
it("flush is a no-op without persist config", async () => {
|
||||
const r = new ServiceRegistry();
|
||||
await r.flush();
|
||||
await r.load();
|
||||
});
|
||||
|
||||
it("load no-ops when no snapshot is present", async () => {
|
||||
const storage = new MemStorage();
|
||||
const r = new ServiceRegistry({
|
||||
storage,
|
||||
persistKey: "registry:absent",
|
||||
});
|
||||
await r.load();
|
||||
expect(r.size).toBe(0);
|
||||
});
|
||||
|
||||
it("load rejects corrupted JSON", async () => {
|
||||
const storage = new MemStorage();
|
||||
storage.map.set("registry:bad", new TextEncoder().encode("not-json"));
|
||||
const r = new ServiceRegistry({
|
||||
storage,
|
||||
persistKey: "registry:bad",
|
||||
});
|
||||
try {
|
||||
await r.load();
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SvcDiscError).code).toBe(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("load rejects wrong shape", async () => {
|
||||
const storage = new MemStorage();
|
||||
storage.map.set(
|
||||
"registry:shape",
|
||||
new TextEncoder().encode(JSON.stringify({ not: "array" }))
|
||||
);
|
||||
const r = new ServiceRegistry({
|
||||
storage,
|
||||
persistKey: "registry:shape",
|
||||
});
|
||||
try {
|
||||
await r.load();
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SvcDiscError).code).toBe(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("load filters out invalid descriptor entries", async () => {
|
||||
const storage = new MemStorage();
|
||||
const clock = new FakeClock();
|
||||
storage.map.set(
|
||||
"registry:mixed",
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "ok",
|
||||
name: "n",
|
||||
address: "a",
|
||||
status: "healthy",
|
||||
registeredAt: clock.t,
|
||||
lastSeen: clock.t,
|
||||
},
|
||||
{ id: "bad" }, // missing fields
|
||||
null,
|
||||
"string",
|
||||
])
|
||||
)
|
||||
);
|
||||
const r = new ServiceRegistry({
|
||||
storage,
|
||||
persistKey: "registry:mixed",
|
||||
clock: clock.clock,
|
||||
});
|
||||
await r.load();
|
||||
expect(r.size).toBe(1);
|
||||
expect(r.get("ok")?.id).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServiceRegistry property tests", () => {
|
||||
it("TTL expiry is monotonic in clock time", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 10_000 }),
|
||||
fc.integer({ min: 0, max: 100_000 }),
|
||||
(ttlMs, advance) => {
|
||||
const clock = new FakeClock();
|
||||
const r = new ServiceRegistry({
|
||||
ttlMs,
|
||||
clock: clock.clock,
|
||||
});
|
||||
r.register(baseInput);
|
||||
clock.advance(advance);
|
||||
const alive = r.get("a1");
|
||||
if (advance > ttlMs) {
|
||||
return alive === undefined;
|
||||
}
|
||||
return alive?.id === "a1";
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("query is deterministic for a given snapshot", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
name: fc.constantFrom("api", "worker"),
|
||||
region: fc.constantFrom("us", "eu"),
|
||||
}),
|
||||
{ maxLength: 20 }
|
||||
),
|
||||
fc.constantFrom("api", "worker"),
|
||||
fc.constantFrom("us", "eu"),
|
||||
(entries, name, region) => {
|
||||
const r = new ServiceRegistry();
|
||||
const seen = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (seen.has(e.id)) continue;
|
||||
seen.add(e.id);
|
||||
r.register({
|
||||
...baseInput,
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
region: e.region,
|
||||
});
|
||||
}
|
||||
const a = r.query({ name, region }).map((s) => s.id);
|
||||
const b = r.query({ name, region }).map((s) => s.id);
|
||||
return (
|
||||
a.length === b.length && a.every((v, i) => v === b[i])
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import type { Clock, EventBusLike } from "@suaveplan/types";
|
||||
import { SVC_DISC_CODES, SvcDiscError } from "../../errors/errors.js";
|
||||
import {
|
||||
bindActiveServicesGauge,
|
||||
recordQuery,
|
||||
recordQueryDuration,
|
||||
recordRegistration,
|
||||
} from "../../otel/otel.js";
|
||||
import type {
|
||||
RegistryEvent,
|
||||
ServiceDescriptor,
|
||||
ServiceQuery,
|
||||
ServiceRegistryConfig,
|
||||
ServiceRegistryEventMap,
|
||||
ServiceRegistryStorageLike,
|
||||
ServiceStatus,
|
||||
} from "../../types/types.js";
|
||||
import {
|
||||
RegistrationInputSchema,
|
||||
ServiceQuerySchema,
|
||||
ServiceRegistryConfigSchema,
|
||||
} from "../../types/types.schemas.js";
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/** Input accepted by `register()`: everything except registry-managed timestamps. */
|
||||
export interface RegisterInput {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly address: string;
|
||||
readonly status?: ServiceStatus;
|
||||
readonly version?: string;
|
||||
readonly region?: string;
|
||||
readonly tags?: readonly string[];
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface WatcherSlot {
|
||||
readonly query: ServiceQuery;
|
||||
push(event: RegistryEvent): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal `ServiceRegistry`.
|
||||
*
|
||||
* In-memory by default; optionally backed by an `AbstractStorage`-shaped
|
||||
* `ServiceRegistryStorageLike` for cross-restart persistence and an
|
||||
* `EventBusLike<ServiceRegistryEventMap>` for fan-out to a shared bus.
|
||||
*
|
||||
* - `register()` is upsert by default: a repeat call for the same `id`
|
||||
* refreshes `lastSeen` and re-validates the descriptor. Use
|
||||
* `register(input, { strict: true })` to reject duplicates with
|
||||
* `SVC_DISC.DUPLICATE_REGISTRATION`.
|
||||
* - `query()` returns a snapshot filtered by name/status/region/tag-superset.
|
||||
* - `watch()` returns an `AsyncIterableIterator<RegistryEvent>` that yields
|
||||
* only events matching the query. The iterator terminates when the optional
|
||||
* `AbortSignal` aborts, when the registry is `dispose()`d, or when the
|
||||
* consumer breaks the loop.
|
||||
* - TTL pruning happens on every read (`query()`, `list()`, `get()`) and on
|
||||
* the explicit `prune()` entry point. Pruned entries emit `expired`.
|
||||
*/
|
||||
export class ServiceRegistry {
|
||||
private readonly services: Map<string, ServiceDescriptor> = new Map();
|
||||
private readonly maxServices: number;
|
||||
private readonly ttlMs: number;
|
||||
private readonly clock: Clock;
|
||||
private readonly storage: ServiceRegistryStorageLike | undefined;
|
||||
private readonly persistKey: string | undefined;
|
||||
private readonly bus: EventBusLike<ServiceRegistryEventMap> | undefined;
|
||||
private readonly watchers: Set<WatcherSlot> = new Set();
|
||||
private readonly disposeGauge: () => void;
|
||||
private disposed = false;
|
||||
|
||||
public constructor(config: ServiceRegistryConfig = {}) {
|
||||
const parsed = ServiceRegistryConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS,
|
||||
"service-registry.invalid-options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
this.maxServices = parsed.data.maxServices;
|
||||
this.ttlMs = parsed.data.ttlMs;
|
||||
this.clock = (parsed.data.clock as Clock | undefined) ?? defaultClock;
|
||||
this.storage = parsed.data.storage as
|
||||
| ServiceRegistryStorageLike
|
||||
| undefined;
|
||||
this.persistKey = parsed.data.persistKey;
|
||||
this.bus = parsed.data.bus as
|
||||
| EventBusLike<ServiceRegistryEventMap>
|
||||
| undefined;
|
||||
this.disposeGauge = bindActiveServicesGauge(this.gaugeSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Untouched-by-prune snapshot used as the OTel gauge source. Exposed as a
|
||||
* bound method so the OTel callback can capture it without holding a
|
||||
* reference to the registry instance.
|
||||
*/
|
||||
public readonly gaugeSize = (): number => this.services.size;
|
||||
|
||||
/**
|
||||
* Register or refresh a service instance.
|
||||
*
|
||||
* @throws {SvcDiscError} `INVALID_OPTIONS` if the descriptor fails Zod parse.
|
||||
* @throws {SvcDiscError} `REGISTRY_FULL` if at capacity and the id is new.
|
||||
* @throws {SvcDiscError} `DUPLICATE_REGISTRATION` only when `strict: true`
|
||||
* and the id is already present.
|
||||
*/
|
||||
public register(
|
||||
input: RegisterInput,
|
||||
options: { readonly strict?: boolean } = {}
|
||||
): ServiceDescriptor {
|
||||
if (this.disposed) {
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS,
|
||||
"service-registry.disposed"
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = RegistrationInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
recordRegistration("rejected");
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS,
|
||||
"service-registry.invalid-descriptor",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const now = this.clock();
|
||||
const existing = this.services.get(data.id);
|
||||
|
||||
if (existing) {
|
||||
if (options.strict) {
|
||||
recordRegistration("rejected");
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.DUPLICATE_REGISTRATION,
|
||||
"service-registry.duplicate-registration"
|
||||
);
|
||||
}
|
||||
const refreshed = buildDescriptor(data, existing.registeredAt, now);
|
||||
this.services.set(data.id, refreshed);
|
||||
recordRegistration("refreshed");
|
||||
this.emit({ type: "registered", service: refreshed });
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
if (this.services.size >= this.maxServices) {
|
||||
recordRegistration("rejected");
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.REGISTRY_FULL,
|
||||
"service-registry.registry-full"
|
||||
);
|
||||
}
|
||||
|
||||
const next = buildDescriptor(data, now, now);
|
||||
this.services.set(data.id, next);
|
||||
recordRegistration("registered");
|
||||
this.emit({ type: "registered", service: next });
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update `lastSeen` for an existing service. No-op (and returns `undefined`)
|
||||
* if the id is unknown or expired.
|
||||
*/
|
||||
public refresh(id: string): ServiceDescriptor | undefined {
|
||||
this.pruneExpired();
|
||||
const current = this.services.get(id);
|
||||
if (!current) return undefined;
|
||||
const updated = buildDescriptor(
|
||||
current,
|
||||
current.registeredAt,
|
||||
this.clock()
|
||||
);
|
||||
this.services.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister a service. Returns the removed descriptor or `undefined` if
|
||||
* not found.
|
||||
*/
|
||||
public deregister(id: string): ServiceDescriptor | undefined {
|
||||
const removed = this.services.get(id);
|
||||
if (!removed) return undefined;
|
||||
this.services.delete(id);
|
||||
this.emit({ type: "deregistered", service: removed });
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Get one service by id, or `undefined` if not found / expired. */
|
||||
public get(id: string): ServiceDescriptor | undefined {
|
||||
this.pruneExpired();
|
||||
return this.services.get(id);
|
||||
}
|
||||
|
||||
/** Return every non-expired descriptor (snapshot). */
|
||||
public list(): ServiceDescriptor[] {
|
||||
this.pruneExpired();
|
||||
return Array.from(this.services.values());
|
||||
}
|
||||
|
||||
/** Snapshot count of services known to the registry (post-prune). */
|
||||
public get size(): number {
|
||||
this.pruneExpired();
|
||||
return this.services.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot-query the registry. Throws `QUERY_INVALID` if `query` fails Zod
|
||||
* parse.
|
||||
*/
|
||||
public query(query: ServiceQuery = {}): ServiceDescriptor[] {
|
||||
const parsed = ServiceQuerySchema.safeParse(query);
|
||||
if (!parsed.success) {
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.QUERY_INVALID,
|
||||
"service-registry.query-invalid",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
const start = this.clock();
|
||||
recordQuery();
|
||||
this.pruneExpired();
|
||||
const out: ServiceDescriptor[] = [];
|
||||
for (const svc of this.services.values()) {
|
||||
if (matches(svc, normalizeQuery(parsed.data))) out.push(svc);
|
||||
}
|
||||
recordQueryDuration(this.clock() - start);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit prune of expired entries. Returns the count removed. Each pruned
|
||||
* service is announced as `expired` on the bus and to active watchers.
|
||||
*/
|
||||
public prune(): number {
|
||||
return this.pruneExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream registry events matching `query`. The iterator terminates when:
|
||||
*
|
||||
* - `signal` aborts (yields nothing further; loop exits cleanly).
|
||||
* - the registry is `dispose()`d.
|
||||
* - the consumer breaks the for-await loop (cleanup runs in `return()`).
|
||||
*
|
||||
* @throws {SvcDiscError} `QUERY_INVALID` if `query` fails Zod parse.
|
||||
*/
|
||||
public watch(
|
||||
query: ServiceQuery = {},
|
||||
signal?: AbortSignal
|
||||
): AsyncIterableIterator<RegistryEvent> {
|
||||
const parsed = ServiceQuerySchema.safeParse(query);
|
||||
if (!parsed.success) {
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.QUERY_INVALID,
|
||||
"service-registry.query-invalid",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return this.createWatchIterator(normalizeQuery(parsed.data), signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a JSON snapshot through the configured `storage` (no-op when
|
||||
* persistence is not configured).
|
||||
*/
|
||||
public async flush(signal?: AbortSignal): Promise<void> {
|
||||
if (!this.storage || !this.persistKey) return;
|
||||
this.pruneExpired();
|
||||
const payload = JSON.stringify(Array.from(this.services.values()));
|
||||
await this.storage.set(
|
||||
this.persistKey,
|
||||
new TextEncoder().encode(payload),
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the registry from a previously-`flush()`ed snapshot. Silently
|
||||
* no-ops when persistence is not configured or no snapshot exists. Invalid
|
||||
* payloads are surfaced as `INVALID_OPTIONS` rather than silently ignored.
|
||||
*/
|
||||
public async load(signal?: AbortSignal): Promise<void> {
|
||||
if (!this.storage || !this.persistKey) return;
|
||||
const bytes = await this.storage.get(this.persistKey, signal);
|
||||
if (!bytes) return;
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch (cause) {
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS,
|
||||
"service-registry.persisted-corrupted",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.INVALID_OPTIONS,
|
||||
"service-registry.persisted-shape"
|
||||
);
|
||||
}
|
||||
this.services.clear();
|
||||
for (const entry of raw) {
|
||||
if (isValidDescriptor(entry)) this.services.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the registry. All active watchers terminate cleanly and the
|
||||
* OTel gauge is unbound. After `dispose()`, calls to `register()` throw.
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const w of this.watchers) w.close();
|
||||
this.watchers.clear();
|
||||
this.disposeGauge();
|
||||
}
|
||||
|
||||
private emit(event: RegistryEvent): void {
|
||||
// Fan-out to bus first (observers must not throw).
|
||||
if (this.bus) {
|
||||
const topic: keyof ServiceRegistryEventMap =
|
||||
event.type === "registered"
|
||||
? "service:registered"
|
||||
: event.type === "deregistered"
|
||||
? "service:deregistered"
|
||||
: "service:expired";
|
||||
this.bus.emit(topic, event.service);
|
||||
}
|
||||
// Then push to watchers (they apply per-watcher query filter).
|
||||
for (const w of this.watchers) {
|
||||
if (matches(event.service, w.query)) w.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
private pruneExpired(): number {
|
||||
const now = this.clock();
|
||||
let removed = 0;
|
||||
for (const [id, svc] of this.services) {
|
||||
if (now - svc.lastSeen > this.ttlMs) {
|
||||
this.services.delete(id);
|
||||
removed += 1;
|
||||
this.emit({ type: "expired", service: svc });
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private createWatchIterator(
|
||||
query: ServiceQuery,
|
||||
signal: AbortSignal | undefined
|
||||
): AsyncIterableIterator<RegistryEvent> {
|
||||
const buffer: RegistryEvent[] = [];
|
||||
let resolveNext:
|
||||
| ((step: IteratorResult<RegistryEvent>) => void)
|
||||
| null = null;
|
||||
let closed = false;
|
||||
|
||||
const slot: WatcherSlot = {
|
||||
query,
|
||||
push: (event: RegistryEvent): void => {
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
r({ value: event, done: false });
|
||||
} else {
|
||||
buffer.push(event);
|
||||
}
|
||||
},
|
||||
close: (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
r({ value: undefined, done: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
this.watchers.add(slot);
|
||||
|
||||
const onAbort = (): void => {
|
||||
slot.close();
|
||||
this.watchers.delete(slot);
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
const iter: AsyncIterableIterator<RegistryEvent> = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
next: (): Promise<IteratorResult<RegistryEvent>> => {
|
||||
if (buffer.length > 0) {
|
||||
const value = buffer.shift() as RegistryEvent;
|
||||
return Promise.resolve({ value, done: false });
|
||||
}
|
||||
if (closed) {
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
}
|
||||
return new Promise<IteratorResult<RegistryEvent>>((resolve) => {
|
||||
resolveNext = resolve;
|
||||
});
|
||||
},
|
||||
return: (): Promise<IteratorResult<RegistryEvent>> => {
|
||||
slot.close();
|
||||
this.watchers.delete(slot);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
},
|
||||
};
|
||||
|
||||
return iter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ServiceDescriptor` from validated input + registry timestamps,
|
||||
* omitting optional fields that came through as `undefined`. This keeps the
|
||||
* stored shape compatible with `exactOptionalPropertyTypes: true`.
|
||||
*/
|
||||
function buildDescriptor(
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
status: ServiceStatus;
|
||||
version?: string | undefined;
|
||||
region?: string | undefined;
|
||||
tags?: readonly string[] | undefined;
|
||||
metadata?: Readonly<Record<string, unknown>> | undefined;
|
||||
},
|
||||
registeredAt: number,
|
||||
lastSeen: number
|
||||
): ServiceDescriptor {
|
||||
const out: {
|
||||
-readonly [K in keyof ServiceDescriptor]: ServiceDescriptor[K];
|
||||
} = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
address: data.address,
|
||||
status: data.status,
|
||||
registeredAt,
|
||||
lastSeen,
|
||||
};
|
||||
if (data.version !== undefined) out.version = data.version;
|
||||
if (data.region !== undefined) out.region = data.region;
|
||||
if (data.tags !== undefined) out.tags = data.tags;
|
||||
if (data.metadata !== undefined) out.metadata = data.metadata;
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeQuery(data: {
|
||||
name?: string | undefined;
|
||||
status?: ServiceStatus | undefined;
|
||||
region?: string | undefined;
|
||||
tags?: string[] | undefined;
|
||||
}): ServiceQuery {
|
||||
const q: {
|
||||
-readonly [K in keyof ServiceQuery]: ServiceQuery[K];
|
||||
} = {};
|
||||
if (data.name !== undefined) q.name = data.name;
|
||||
if (data.status !== undefined) q.status = data.status;
|
||||
if (data.region !== undefined) q.region = data.region;
|
||||
if (data.tags !== undefined) q.tags = data.tags;
|
||||
return q;
|
||||
}
|
||||
|
||||
function matches(svc: ServiceDescriptor, q: ServiceQuery): boolean {
|
||||
if (q.name !== undefined && svc.name !== q.name) return false;
|
||||
if (q.status !== undefined && svc.status !== q.status) return false;
|
||||
if (q.region !== undefined && svc.region !== q.region) return false;
|
||||
if (q.tags && q.tags.length > 0) {
|
||||
const have = svc.tags ?? [];
|
||||
for (const t of q.tags) if (!have.includes(t)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidDescriptor(value: unknown): value is ServiceDescriptor {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v["id"] === "string" &&
|
||||
typeof v["name"] === "string" &&
|
||||
typeof v["address"] === "string" &&
|
||||
typeof v["status"] === "string" &&
|
||||
typeof v["registeredAt"] === "number" &&
|
||||
typeof v["lastSeen"] === "number"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error class `SvcDiscError` and the frozen code registry
|
||||
`SVC_DISC_CODES` used by every public boundary of `@suaveplan/service-discovery`.
|
||||
suaveplan policy is to surface failure as discriminated, typed errors keyed by a
|
||||
stable code, so consumers can branch on `error.code` rather than parsing
|
||||
free-form messages or matching `instanceof` chains.
|
||||
|
||||
## Features
|
||||
|
||||
- `SVC_DISC_CODES` registry built with `defineCodes("SVC_DISC", [...])`.
|
||||
- Four codes covering the full failure surface: `DUPLICATE_REGISTRATION`,
|
||||
`QUERY_INVALID`, `REGISTRY_FULL`, `INVALID_OPTIONS`.
|
||||
- `SvcDiscError` extends `SuaveplanError<SvcDiscErrorCode>` so the suaveplan-wide
|
||||
message-resolver, redaction, and OTel span-event adapters all work.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SVC_DISC_CODES,
|
||||
SvcDiscError,
|
||||
} from "@suaveplan/service-discovery";
|
||||
|
||||
try {
|
||||
// some registry op
|
||||
} catch (err) {
|
||||
if (err instanceof SvcDiscError && err.code === SVC_DISC_CODES.REGISTRY_FULL) {
|
||||
// backpressure
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SvcDiscError, SVC_DISC_CODES } from "@suaveplan/service-discovery";
|
||||
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.DUPLICATE_REGISTRATION,
|
||||
"service-registry.duplicate-registration",
|
||||
{ cause: new Error("instance id collision") }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SVC_DISC_CODES` — frozen `Record<Code, "SVC_DISC.<CODE>">` produced by
|
||||
`defineCodes`.
|
||||
- `SvcDiscErrorCode` — string-literal union derived from the registry.
|
||||
- `SvcDiscError(code, messageKey, options?)` — constructor; `options.cause`
|
||||
threads the wrapped failure for OTel and redacted logging.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All four codes are intentionally narrow. `INVALID_OPTIONS` covers every
|
||||
Zod-rejected option struct (config, descriptor input). `QUERY_INVALID` is
|
||||
separated from `INVALID_OPTIONS` because `query()` and `watch()` are
|
||||
called every request, while options are validated once at construction —
|
||||
keeping the two distinct lets dashboards differentiate frequent client
|
||||
errors from rare misconfiguration. `REGISTRY_FULL` raises only on
|
||||
`register()` over the soft cap; `refresh()` of an existing id never raises
|
||||
this. `DUPLICATE_REGISTRATION` is reserved for the strict registration
|
||||
path; the registry's default `upsert` semantics treat repeat `register()`
|
||||
calls as refreshes and do not raise.
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SVC_DISC_CODES, SvcDiscError } from "./errors.js";
|
||||
|
||||
describe("SvcDiscError", () => {
|
||||
it("exposes all four codes", () => {
|
||||
expect(SVC_DISC_CODES.DUPLICATE_REGISTRATION).toBe(
|
||||
"SVC_DISC.DUPLICATE_REGISTRATION"
|
||||
);
|
||||
expect(SVC_DISC_CODES.QUERY_INVALID).toBe("SVC_DISC.QUERY_INVALID");
|
||||
expect(SVC_DISC_CODES.REGISTRY_FULL).toBe("SVC_DISC.REGISTRY_FULL");
|
||||
expect(SVC_DISC_CODES.INVALID_OPTIONS).toBe("SVC_DISC.INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("constructs with code + messageKey + cause", () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new SvcDiscError(
|
||||
SVC_DISC_CODES.QUERY_INVALID,
|
||||
"test.message",
|
||||
{ cause }
|
||||
);
|
||||
expect(err).toBeInstanceOf(SvcDiscError);
|
||||
expect(err.code).toBe(SVC_DISC_CODES.QUERY_INVALID);
|
||||
expect(err.name).toBe("SvcDiscError");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error codes emitted by `@suaveplan/service-discovery`.
|
||||
*
|
||||
* - `DUPLICATE_REGISTRATION` — `register()` called with an id already present.
|
||||
* - `QUERY_INVALID` — supplied query failed Zod parse.
|
||||
* - `REGISTRY_FULL` — capacity (`maxServices`) reached.
|
||||
* - `INVALID_OPTIONS` — caller supplied an option struct that failed Zod parse.
|
||||
*/
|
||||
export const SVC_DISC_CODES = defineCodes("SVC_DISC", [
|
||||
"DUPLICATE_REGISTRATION",
|
||||
"QUERY_INVALID",
|
||||
"REGISTRY_FULL",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type SvcDiscErrorCode =
|
||||
(typeof SVC_DISC_CODES)[keyof typeof SVC_DISC_CODES];
|
||||
|
||||
/**
|
||||
* Structured error class for `@suaveplan/service-discovery`. Every public
|
||||
* boundary throws this on failure; consumers branch on `error.code`.
|
||||
*/
|
||||
export class SvcDiscError extends SuaveplanError<SvcDiscErrorCode> {
|
||||
public constructor(
|
||||
code: SvcDiscErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "SvcDiscError";
|
||||
}
|
||||
}
|
||||
40
packages/networking/network/service-discovery/src/index.ts
Normal file
40
packages/networking/network/service-discovery/src/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Core
|
||||
|
||||
export type { RegisterInput } from "./core/service-registry/service-registry.js";
|
||||
export { ServiceRegistry } from "./core/service-registry/service-registry.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
SVC_DISC_CODES,
|
||||
SvcDiscError,
|
||||
type SvcDiscErrorCode,
|
||||
} from "./errors/errors.js";
|
||||
|
||||
// OTel
|
||||
export {
|
||||
bindActiveServicesGauge,
|
||||
type RegistrationOutcome,
|
||||
recordQuery,
|
||||
recordQueryDuration,
|
||||
recordRegistration,
|
||||
} from "./otel/otel.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
RegistryEvent,
|
||||
ServiceDescriptor,
|
||||
ServiceQuery,
|
||||
ServiceRegistryConfig,
|
||||
ServiceRegistryEventMap,
|
||||
ServiceRegistryStorageLike,
|
||||
ServiceStatus,
|
||||
} from "./types/types.js";
|
||||
|
||||
// Schemas
|
||||
export {
|
||||
RegistrationInputSchema,
|
||||
ServiceDescriptorSchema,
|
||||
ServiceQuerySchema,
|
||||
ServiceRegistryConfigSchema,
|
||||
ServiceStatusSchema,
|
||||
} from "./types/types.schemas.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# OTel instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single-source OTel instruments for `@suaveplan/service-discovery`. Centralising
|
||||
counter/histogram/gauge creation here keeps cardinality bounded, avoids the
|
||||
common bug where multiple meters get created under similar names, and gives
|
||||
dashboards a single canonical set of metric IDs to chart.
|
||||
|
||||
## Features
|
||||
|
||||
- `svc_disc.registrations_total{outcome}` counter — outcomes are `registered`,
|
||||
`refreshed`, `rejected` (low-cardinality enum).
|
||||
- `svc_disc.queries_total` counter — incremented once per `query()` call.
|
||||
- `svc_disc.query_duration_ms` histogram — wall-clock latency per query.
|
||||
- `svc_disc.active_services` observable gauge — bound to a registry's `size`
|
||||
getter at construction; the meter pulls on scrape.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordRegistration,
|
||||
recordQuery,
|
||||
recordQueryDuration,
|
||||
} from "@suaveplan/service-discovery";
|
||||
|
||||
recordRegistration("registered");
|
||||
recordQuery();
|
||||
recordQueryDuration(1.42);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { bindActiveServicesGauge } from "@suaveplan/service-discovery";
|
||||
|
||||
const services = new Set<string>();
|
||||
const dispose = bindActiveServicesGauge(() => services.size);
|
||||
|
||||
// ...later, on shutdown:
|
||||
dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordRegistration(outcome)` — increments
|
||||
`svc_disc.registrations_total{outcome}` by 1. `outcome` is the
|
||||
`RegistrationOutcome` union.
|
||||
- `recordQuery()` — increments `svc_disc.queries_total` by 1.
|
||||
- `recordQueryDuration(ms)` — records into `svc_disc.query_duration_ms`. Values
|
||||
below zero are clamped to 0.
|
||||
- `bindActiveServicesGauge(getSize)` — registers an OTel observable-gauge
|
||||
callback. Returns a disposer that unbinds the callback.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `outcome` label is restricted to three string values so dashboards can
|
||||
chart success vs refresh vs rejection without unbounded cardinality. The
|
||||
gauge is observable (not synchronous) because the canonical "current size"
|
||||
of the registry is best read on scrape — that way no separate metric write
|
||||
is required on every add/delete and the gauge cannot drift from the actual
|
||||
state. `bindActiveServicesGauge` returns its own disposer so registries can
|
||||
properly tear down their instrumentation on `dispose()` without leaking
|
||||
callbacks across test runs.
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
bindActiveServicesGauge,
|
||||
makeGaugeCallback,
|
||||
recordQuery,
|
||||
recordQueryDuration,
|
||||
recordRegistration,
|
||||
} from "./otel.js";
|
||||
|
||||
describe("otel instruments", () => {
|
||||
it("records every registration outcome", () => {
|
||||
// Smoke: must not throw. Metric backend defaults to a no-op meter
|
||||
// provider unless an OTel SDK is wired by the host.
|
||||
recordRegistration("registered");
|
||||
recordRegistration("refreshed");
|
||||
recordRegistration("rejected");
|
||||
recordQuery();
|
||||
});
|
||||
|
||||
it("clamps negative durations to zero", () => {
|
||||
recordQueryDuration(-10);
|
||||
recordQueryDuration(0);
|
||||
recordQueryDuration(1.5);
|
||||
});
|
||||
|
||||
it("binds and unbinds the active-services gauge", () => {
|
||||
let calls = 0;
|
||||
const dispose = bindActiveServicesGauge(() => {
|
||||
calls += 1;
|
||||
return 42;
|
||||
});
|
||||
expect(typeof dispose).toBe("function");
|
||||
// The observable callback only fires on scrape, which the no-op
|
||||
// meter does not schedule — we cannot assert `calls > 0` without
|
||||
// a wired SDK. Disposer must be safe to call regardless.
|
||||
dispose();
|
||||
expect(calls).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("gauge callback observes the size on scrape", () => {
|
||||
const cb = makeGaugeCallback(() => 7);
|
||||
let observed = -1;
|
||||
cb({
|
||||
observe(v) {
|
||||
observed = v;
|
||||
},
|
||||
});
|
||||
expect(observed).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/service-discovery";
|
||||
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/**
|
||||
* Counter incremented on every `register()` attempt.
|
||||
* Labelled by `{outcome}` — one of `"registered" | "refreshed" | "rejected"`.
|
||||
*/
|
||||
const registrationsCounter = meter.createCounter(
|
||||
"svc_disc.registrations_total",
|
||||
{
|
||||
description:
|
||||
"Total registration attempts, by outcome (registered/refreshed/rejected).",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Counter incremented on every `query()` call.
|
||||
*/
|
||||
const queriesCounter = meter.createCounter("svc_disc.queries_total", {
|
||||
description: "Total query calls against the registry.",
|
||||
});
|
||||
|
||||
/**
|
||||
* Histogram of query durations in milliseconds.
|
||||
*/
|
||||
const queryDurationHistogram = meter.createHistogram(
|
||||
"svc_disc.query_duration_ms",
|
||||
{
|
||||
description: "Wall-clock duration of a query in milliseconds.",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable gauge of currently-active (non-expired) services. The registry
|
||||
* registers its `size` callback once at construction; the meter pulls on
|
||||
* scrape.
|
||||
*/
|
||||
const activeServicesGauge = meter.createObservableGauge(
|
||||
"svc_disc.active_services",
|
||||
{
|
||||
description: "Currently-tracked services in the registry.",
|
||||
}
|
||||
);
|
||||
|
||||
/** Outcome variants for `svc_disc.registrations_total{outcome}`. */
|
||||
export type RegistrationOutcome = "registered" | "refreshed" | "rejected";
|
||||
|
||||
/**
|
||||
* Record a registration outcome.
|
||||
*/
|
||||
export function recordRegistration(outcome: RegistrationOutcome): void {
|
||||
registrationsCounter.add(1, { outcome });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one query call.
|
||||
*/
|
||||
export function recordQuery(): void {
|
||||
queriesCounter.add(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the wall-clock duration of a query.
|
||||
*/
|
||||
export function recordQueryDuration(ms: number): void {
|
||||
queryDurationHistogram.record(Math.max(0, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a size-callback to the observable gauge. Returns a disposer that
|
||||
* unbinds the callback. Pass the registry's `size` getter; the meter will
|
||||
* pull on scrape.
|
||||
*/
|
||||
export function bindActiveServicesGauge(getSize: () => number): () => void {
|
||||
const callback = makeGaugeCallback(getSize);
|
||||
activeServicesGauge.addCallback(callback);
|
||||
return () => {
|
||||
activeServicesGauge.removeCallback(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the callback that the OTel observable-gauge invokes on scrape. Exposed
|
||||
* so tests can drive the callback against a stub observable without spinning
|
||||
* up a real OTel SDK.
|
||||
*/
|
||||
export function makeGaugeCallback(
|
||||
getSize: () => number
|
||||
): (observable: { observe(value: number): void }) => void {
|
||||
return (observable) => {
|
||||
observable.observe(getSize());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# Types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Shared type contracts for `@suaveplan/service-discovery`. These types model the
|
||||
universal service-registry domain: descriptors that identify and address a
|
||||
service instance, queries that filter the registry, and registry events that
|
||||
flow through the watch stream and optional event bus. Keeping the types in a
|
||||
single sibling module means every Wave-2 discovery plugin (`crdt`, `peerjs`,
|
||||
`trystero`, `webtorrent`, `gundb`, `hybrid`) can share the same descriptor
|
||||
shape and event topics without re-declaring them.
|
||||
|
||||
## Features
|
||||
|
||||
- `ServiceDescriptor` — identity + routing + metadata + registry-managed
|
||||
timestamps.
|
||||
- `ServiceStatus` — `healthy` / `unhealthy` / `starting` / `stopping`.
|
||||
- `ServiceQuery` — AND-conjoined filter by name / status / region / tag-superset.
|
||||
- `RegistryEvent` — discriminated union of `registered` / `deregistered` /
|
||||
`expired` emitted by the watch stream.
|
||||
- `ServiceRegistryStorageLike` — structural subset of `@suaveplan/storage`
|
||||
`AbstractStorage`, opt-in for cross-restart persistence.
|
||||
- `ServiceRegistryEventMap` — bus topics consumed via the structural
|
||||
`EventBusLike<TMap>` from `@suaveplan/types`.
|
||||
- `ServiceRegistryConfig` — registry constructor options.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ServiceDescriptor,
|
||||
ServiceQuery,
|
||||
} from "@suaveplan/service-discovery";
|
||||
|
||||
const svc: ServiceDescriptor = {
|
||||
id: "api-1",
|
||||
name: "api",
|
||||
address: "10.0.0.1:8080",
|
||||
status: "healthy",
|
||||
registeredAt: 0,
|
||||
lastSeen: 0,
|
||||
};
|
||||
|
||||
const query: ServiceQuery = { name: "api", status: "healthy" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ServiceRegistryConfig,
|
||||
ServiceRegistryEventMap,
|
||||
} from "@suaveplan/service-discovery";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
declare const bus: EventBusLike<ServiceRegistryEventMap>;
|
||||
|
||||
const config: ServiceRegistryConfig = {
|
||||
ttlMs: 30_000,
|
||||
maxServices: 5_000,
|
||||
bus,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ServiceStatus` — string-literal union; no methods.
|
||||
- `ServiceDescriptor` — readonly fields; the registry stamps `registeredAt`
|
||||
and `lastSeen` so callers may pass these as `0` when constructing inputs.
|
||||
- `ServiceQuery` — every field optional; an empty query matches every entry.
|
||||
- `RegistryEvent` — `{ type: "registered" | "deregistered" | "expired", service }`.
|
||||
- `ServiceRegistryStorageLike` — `get`/`set`/`delete` keyed by string, value
|
||||
is `Uint8Array`.
|
||||
- `ServiceRegistryConfig.maxServices` — soft cap, default `10_000`. Adding a
|
||||
new service when the registry is full raises `SVC_DISC.REGISTRY_FULL`.
|
||||
- `ServiceRegistryConfig.ttlMs` — default `60_000`; entries older than this
|
||||
are pruned and emit `expired`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The descriptor's `registeredAt` and `lastSeen` are wall-clock milliseconds
|
||||
(`number`) so the registry can use the injectable `Clock` from
|
||||
`@suaveplan/types`. Bus integration uses the structural `EventBusLike<TMap>`
|
||||
type — not a hard dep on `@suaveplan/events` or `@suaveplan/network` — so
|
||||
this package stays universal and avoids cycles when Wave-2 plugins
|
||||
register against the same bus their network node uses. The storage shim
|
||||
matches the `get` / `set` / `delete` slice of `AbstractStorage` so callers
|
||||
can pass a `MemoryStorage`, `OPFSStorage`, or `S3Storage` without the
|
||||
registry depending on `@suaveplan/storage` directly.
|
||||
@@ -0,0 +1,54 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary schemas. Schemas validate the *shape* of data; callable
|
||||
* contracts (`ServiceRegistryStorageLike`, `EventBusLike`, `Clock`) are
|
||||
* checked structurally at the type level and never coerced through Zod.
|
||||
*/
|
||||
|
||||
export const ServiceStatusSchema = z.enum([
|
||||
"healthy",
|
||||
"unhealthy",
|
||||
"starting",
|
||||
"stopping",
|
||||
]);
|
||||
|
||||
const RegistrationInputSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
address: z.string().min(1),
|
||||
status: ServiceStatusSchema.default("healthy"),
|
||||
version: z.string().min(1).optional(),
|
||||
region: z.string().min(1).optional(),
|
||||
tags: z.array(z.string().min(1)).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export { RegistrationInputSchema };
|
||||
|
||||
export const ServiceDescriptorSchema = RegistrationInputSchema.extend({
|
||||
registeredAt: z.number().int().nonnegative(),
|
||||
lastSeen: z.number().int().nonnegative(),
|
||||
}).strict();
|
||||
|
||||
export const ServiceQuerySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
status: ServiceStatusSchema.optional(),
|
||||
region: z.string().min(1).optional(),
|
||||
tags: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ServiceRegistryConfigSchema = z
|
||||
.object({
|
||||
maxServices: z.number().int().positive().default(10_000),
|
||||
ttlMs: z.number().int().positive().default(60_000),
|
||||
clock: z.unknown().optional(),
|
||||
storage: z.unknown().optional(),
|
||||
persistKey: z.string().min(1).optional(),
|
||||
bus: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
108
packages/networking/network/service-discovery/src/types/types.ts
Normal file
108
packages/networking/network/service-discovery/src/types/types.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Clock, EventBusLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Health/lifecycle status of a registered service instance.
|
||||
*
|
||||
* - `healthy` — service is registered and serving traffic.
|
||||
* - `unhealthy` — service is registered but failing or unreachable.
|
||||
* - `starting` — service is registering but not yet ready.
|
||||
* - `stopping` — service is draining connections and will deregister soon.
|
||||
*/
|
||||
export type ServiceStatus = "healthy" | "unhealthy" | "starting" | "stopping";
|
||||
|
||||
/**
|
||||
* A single service-instance descriptor stored in the registry.
|
||||
*
|
||||
* Field ordering is intentional: the four required identity fields come first,
|
||||
* followed by optional routing/metadata fields, and finally the registry-managed
|
||||
* timestamps (`registeredAt`, `lastSeen`).
|
||||
*/
|
||||
export interface ServiceDescriptor {
|
||||
/** Unique service-instance id (caller-supplied). */
|
||||
readonly id: string;
|
||||
/** Logical service name; multiple instances may share the same name. */
|
||||
readonly name: string;
|
||||
/** Network address (host[:port]) or opaque endpoint identifier. */
|
||||
readonly address: string;
|
||||
/** Service status. */
|
||||
readonly status: ServiceStatus;
|
||||
/** Optional service version (semver or free-form). */
|
||||
readonly version?: string;
|
||||
/** Optional region/zone for region-aware routing. */
|
||||
readonly region?: string;
|
||||
/** Optional tags for query filtering. */
|
||||
readonly tags?: readonly string[];
|
||||
/** Optional opaque metadata bag. */
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
/** Wall-clock ms of first registration. Set by the registry. */
|
||||
readonly registeredAt: number;
|
||||
/** Wall-clock ms of the most recent heartbeat/refresh. Set by the registry. */
|
||||
readonly lastSeen: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query filter for {@link ServiceRegistry.query} and {@link ServiceRegistry.watch}.
|
||||
*
|
||||
* Every supplied field acts as an AND-conjoined filter; unspecified fields
|
||||
* are not constrained.
|
||||
*/
|
||||
export interface ServiceQuery {
|
||||
readonly name?: string;
|
||||
readonly status?: ServiceStatus;
|
||||
readonly region?: string;
|
||||
/** Match instances whose tag set is a superset of every supplied tag. */
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated-union event emitted by the registry watch stream and bus.
|
||||
*/
|
||||
export type RegistryEvent =
|
||||
| { readonly type: "registered"; readonly service: ServiceDescriptor }
|
||||
| { readonly type: "deregistered"; readonly service: ServiceDescriptor }
|
||||
| { readonly type: "expired"; readonly service: ServiceDescriptor };
|
||||
|
||||
/**
|
||||
* Optional persistent backing for the registry. Matches the subset of
|
||||
* `@suaveplan/storage` `AbstractStorage` the registry needs without taking
|
||||
* a hard runtime dep on it.
|
||||
*/
|
||||
export interface ServiceRegistryStorageLike {
|
||||
get(key: string, signal?: AbortSignal): Promise<Uint8Array | undefined>;
|
||||
set(key: string, value: Uint8Array, signal?: AbortSignal): Promise<void>;
|
||||
delete(key: string, signal?: AbortSignal): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bus topics emitted by the registry when an `EventBusLike` is configured.
|
||||
*/
|
||||
export type ServiceRegistryEventMap = {
|
||||
"service:registered": ServiceDescriptor;
|
||||
"service:deregistered": ServiceDescriptor;
|
||||
"service:expired": ServiceDescriptor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for {@link ServiceRegistry}.
|
||||
*/
|
||||
export interface ServiceRegistryConfig {
|
||||
/** Soft cap on number of stored services. Defaults to 10_000. */
|
||||
readonly maxServices?: number;
|
||||
/**
|
||||
* Time-to-live for a service instance in milliseconds. After this many ms
|
||||
* without a `refresh()` or `register()` update, the entry is considered
|
||||
* expired and pruned on next read or watch tick. Defaults to 60_000.
|
||||
*/
|
||||
readonly ttlMs?: number;
|
||||
/** Injectable wall clock; defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
/** Optional storage backend for cross-restart persistence. */
|
||||
readonly storage?: ServiceRegistryStorageLike;
|
||||
/** Storage key prefix (only relevant when `storage` is supplied). */
|
||||
readonly persistKey?: string;
|
||||
/**
|
||||
* Optional event bus for emitting `service:registered`/`:deregistered`/`:expired`
|
||||
* topics.
|
||||
*/
|
||||
readonly bus?: EventBusLike<ServiceRegistryEventMap>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("service-discovery dep-graph", () => {
|
||||
it("must not have circular dependencies", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.ServiceRegistry).toBeDefined();
|
||||
expect(mod.SVC_DISC_CODES).toBeDefined();
|
||||
expect(mod.SvcDiscError).toBeDefined();
|
||||
expect(mod.recordRegistration).toBeDefined();
|
||||
expect(mod.ServiceRegistryConfigSchema).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/networking/network/service-discovery/vite.config.ts
Normal file
37
packages/networking/network/service-discovery/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/service-discovery",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user