Initial Commit
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# @suaveplan/network-plugin-topology - Tiered network topology: hierarchical 3-tier model, latency-based clustering, group manager, plugin wrapper
|
||||
|
||||
## Summary
|
||||
|
||||
Tiered mesh topology plugin: 3-tier `HierarchicalTopology` (Host / SubHost / Leaf) with promote/demote, latency-bucket-based `LatencyAssigner`, `GroupManager`, plus stability primitives (reorganization circuit breaker, promotion hysteresis, stability-threshold gate) and empty-tier handling.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [group](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
|
||||
<!-- end:auto-docs --> - [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
|
||||
- [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
|
||||
- [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
|
||||
- [group-manager](./src/group/group-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [latency](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [latency-assigner](./src/latency/latency-assigner.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [topology-plugin](./src/plugin/topology-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_ - [hierarchical-topology](./src/topology/hierarchical-topology.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` fo…
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-topology",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Tiered network topology: hierarchical 3-tier model, latency-based clustering, group manager, plugin wrapper",
|
||||
"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" }
|
||||
}
|
||||
},
|
||||
"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/network": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "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,62 @@
|
||||
# NETWORK_TOPO_CODES
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of error codes for the network-plugin-topology plugin. Three codes cover the surface: `INVALID_TIER` for out-of-range or non-integer tier inputs (and for nonsensical promote / demote directions), `PEER_NOT_FOUND` when a topology operation references an unknown peer, and `ALREADY_AT_TIER` when promote / demote is asked for a no-op. The companion `NetworkTopoError` extends `SuaveplanError<NetworkTopoErrorCode>` so consumers pattern-match on `code` rather than parse messages.
|
||||
|
||||
## Features
|
||||
|
||||
- Three frozen codes registered under the `NET_TOPO` namespace.
|
||||
- `NetworkTopoError extends SuaveplanError<NetworkTopoErrorCode>`.
|
||||
- `name: "NetworkTopoError"` on every instance — useful for log filters and dashboard alerts.
|
||||
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
|
||||
- Codes are stable across versions; the registry only ever grows, never shrinks. `INVALID_TIER` is intentionally broad and reused across the topology, latency, and group sub-modules so the code surface stays small.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_TOPO_CODES,
|
||||
NetworkTopoError,
|
||||
} from "@suaveplan/network-plugin-topology";
|
||||
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.INVALID_TIER,
|
||||
"topology.invalid-tier",
|
||||
{ context: { tier: 5 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_TOPO_CODES,
|
||||
NetworkTopoError,
|
||||
} from "@suaveplan/network-plugin-topology";
|
||||
|
||||
try {
|
||||
topology.promote("alice", 0);
|
||||
} catch (err) {
|
||||
if (err instanceof NetworkTopoError) {
|
||||
if (err.code === NETWORK_TOPO_CODES.ALREADY_AT_TIER) {
|
||||
return; // benign
|
||||
}
|
||||
if (err.code === NETWORK_TOPO_CODES.PEER_NOT_FOUND) {
|
||||
await registerAndRetry(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_TOPO_CODES` — frozen `{ INVALID_TIER, PEER_NOT_FOUND, ALREADY_AT_TIER }`.
|
||||
- `type NetworkTopoErrorCode` — union of registered code values.
|
||||
- `class NetworkTopoError extends SuaveplanError<NetworkTopoErrorCode>` — typed throwable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`ALREADY_AT_TIER` is a benign-but-distinct code: a caller asking to promote a peer that is already at the target tier is almost certainly a redundant request, but it should not silently succeed because that masks driver bugs in upstream election logic. Future stretch features (the proposal's resource / partition / geo / stability / empty-tier sub-systems) will likely want their own codes; until those land we keep the registry tight rather than register codes that never fire. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TOPO_CODES, NetworkTopoError } from "./error-codes.js";
|
||||
|
||||
describe("NETWORK_TOPO_CODES", () => {
|
||||
it("registers under NET_TOPO", () => {
|
||||
for (const value of Object.values(NETWORK_TOPO_CODES)) {
|
||||
expect(value).toMatch(/^NET_TOPO\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(NETWORK_TOPO_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
for (const key of [
|
||||
"INVALID_TIER",
|
||||
"PEER_NOT_FOUND",
|
||||
"ALREADY_AT_TIER",
|
||||
] as const) {
|
||||
expect(NETWORK_TOPO_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkTopoError", () => {
|
||||
it("extends SuaveplanError", () => {
|
||||
const err = new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.INVALID_TIER,
|
||||
"topology.invalid-tier"
|
||||
);
|
||||
expect(err.name).toBe("NetworkTopoError");
|
||||
expect(err.code).toBe(NETWORK_TOPO_CODES.INVALID_TIER);
|
||||
});
|
||||
|
||||
it("preserves cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.PEER_NOT_FOUND,
|
||||
"topology.peer-not-found",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const NETWORK_TOPO_CODES = defineCodes("NET_TOPO", [
|
||||
"INVALID_TIER",
|
||||
"PEER_NOT_FOUND",
|
||||
"ALREADY_AT_TIER",
|
||||
] as const);
|
||||
|
||||
export type NetworkTopoErrorCode =
|
||||
(typeof NETWORK_TOPO_CODES)[keyof typeof NETWORK_TOPO_CODES];
|
||||
|
||||
export class NetworkTopoError extends SuaveplanError<NetworkTopoErrorCode> {
|
||||
public constructor(
|
||||
code: NetworkTopoErrorCode,
|
||||
messageKey: string,
|
||||
options?: import("@suaveplan/error").SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetworkTopoError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# GroupManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Tracks group membership across the mesh. Each group has an operator-supplied capacity — there is no built-in default group size. A peer can belong to many groups simultaneously, and the manager publishes `group:membership` on every join / leave so downstream observers (the topology plugin, observability dashboards) can react.
|
||||
|
||||
## Features
|
||||
|
||||
- `defineGroup(groupId, capacity)` is the only way to create a group; capacity must be a positive integer or the call throws.
|
||||
- `join(groupId, peerId)` rejects with `group.full` when the group has reached its capacity, and is a no-op (no event) for already-joined peers so callers can re-join idempotently on heartbeats.
|
||||
- `leave(groupId, peerId)` returns `boolean` — `true` if the peer was actually removed, `false` for unknown groups or peers.
|
||||
- `membersOf` / `groupsOf` / `list` / `getCapacity` / `size` for observability.
|
||||
- `removeGroup(groupId)` fires a `leave` event for every member before deleting the group, so dashboards see a clean teardown.
|
||||
- `clear()` for full reset (e.g. mesh restart).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GroupManager } from "@suaveplan/network-plugin-topology";
|
||||
|
||||
const groups = new GroupManager({ bus });
|
||||
groups.defineGroup("region-A", 32);
|
||||
groups.join("region-A", "alice");
|
||||
groups.join("region-A", "bob");
|
||||
const members = groups.membersOf("region-A");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
GroupManager,
|
||||
NETWORK_TOPO_CODES,
|
||||
NetworkTopoError,
|
||||
} from "@suaveplan/network-plugin-topology";
|
||||
|
||||
const groups = new GroupManager({ bus });
|
||||
groups.defineGroup("hosts", 4);
|
||||
groups.defineGroup("subhosts", 16);
|
||||
|
||||
bus.on("election:result", ({ winnerId }) => {
|
||||
try {
|
||||
groups.join("hosts", winnerId);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof NetworkTopoError &&
|
||||
err.code === NETWORK_TOPO_CODES.INVALID_TIER
|
||||
) {
|
||||
// group full → demote the longest-tenured host first
|
||||
const oldest = groups.membersOf("hosts")[0];
|
||||
if (oldest !== undefined) {
|
||||
groups.leave("hosts", oldest);
|
||||
groups.join("hosts", winnerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface Group { groupId, capacity, members }`.
|
||||
- `interface GroupBus { emit("group:membership", { groupId, peerId, change }) }`.
|
||||
- `interface GroupManagerConfig { bus }`.
|
||||
- `defineGroup(groupId, capacity)` — capacity must be `> 0`.
|
||||
- `removeGroup(groupId)` — emits `leave` for every member before deletion.
|
||||
- `join(groupId, peerId)` — throws `group.full` on capacity breach; no-op on duplicate.
|
||||
- `leave(groupId, peerId): boolean`.
|
||||
- `membersOf(groupId): readonly string[]` / `groupsOf(peerId): readonly string[]` / `list(): readonly Group[]`.
|
||||
- `getCapacity(groupId): number | undefined` / `size(groupId): number`.
|
||||
- `clear()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
We deliberately reuse `NETWORK_TOPO_CODES.INVALID_TIER` for both invalid-capacity and group-full failures rather than introducing dedicated codes — both are operator-visible misconfigurations and the small surface doesn't justify a separate registry. The `defineGroup` upsert path lets operators resize a group at runtime by calling `defineGroup` again with a new capacity; existing members are not evicted even if the new capacity is smaller, so the manager temporarily reports `size > capacity` until peers leave naturally. This is the right shape for elastic deployments where shrinking a group should be a soft signal, not a hard eviction.
|
||||
@@ -0,0 +1,160 @@
|
||||
import { NETWORK_TOPO_CODES, NetworkTopoError } from "../error-codes.js";
|
||||
|
||||
/**
|
||||
* One group definition.
|
||||
*/
|
||||
export interface Group {
|
||||
readonly groupId: string;
|
||||
readonly capacity: number;
|
||||
readonly members: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bus shape for membership events.
|
||||
*/
|
||||
export interface GroupBus {
|
||||
emit(
|
||||
event: "group:membership",
|
||||
payload: {
|
||||
groupId: string;
|
||||
peerId: string;
|
||||
change: "join" | "leave";
|
||||
}
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `GroupManager`. The constructor takes an empty
|
||||
* manager — operators register groups individually via
|
||||
* `defineGroup(groupId, capacity)` so capacity is always operator-supplied.
|
||||
*/
|
||||
export interface GroupManagerConfig {
|
||||
readonly bus: GroupBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks group membership across the mesh. Groups are independently
|
||||
* defined with operator-supplied capacities — there is no "default
|
||||
* group size". Joining a full group raises `INVALID_TIER` (we reuse
|
||||
* the existing topology code namespace rather than introducing a
|
||||
* separate one for this small surface).
|
||||
*/
|
||||
export class GroupManager {
|
||||
private readonly bus: GroupBus;
|
||||
private readonly groups = new Map<
|
||||
string,
|
||||
{ capacity: number; members: Set<string> }
|
||||
>();
|
||||
|
||||
public constructor(config: GroupManagerConfig) {
|
||||
this.bus = config.bus;
|
||||
}
|
||||
|
||||
public defineGroup(groupId: string, capacity: number): void {
|
||||
if (!Number.isInteger(capacity) || capacity <= 0) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.INVALID_TIER,
|
||||
"group.invalid-capacity",
|
||||
{ context: { groupId, capacity } }
|
||||
);
|
||||
}
|
||||
const existing = this.groups.get(groupId);
|
||||
if (existing === undefined) {
|
||||
this.groups.set(groupId, {
|
||||
capacity,
|
||||
members: new Set(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
existing.capacity = capacity;
|
||||
}
|
||||
|
||||
public removeGroup(groupId: string): void {
|
||||
const group = this.groups.get(groupId);
|
||||
if (group === undefined) return;
|
||||
for (const peerId of group.members) {
|
||||
this.bus.emit("group:membership", {
|
||||
groupId,
|
||||
peerId,
|
||||
change: "leave",
|
||||
});
|
||||
}
|
||||
this.groups.delete(groupId);
|
||||
}
|
||||
|
||||
public join(groupId: string, peerId: string): void {
|
||||
const group = this.groups.get(groupId);
|
||||
if (group === undefined) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.PEER_NOT_FOUND,
|
||||
"group.not-found",
|
||||
{ context: { groupId } }
|
||||
);
|
||||
}
|
||||
if (group.members.has(peerId)) return;
|
||||
if (group.members.size >= group.capacity) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.INVALID_TIER,
|
||||
"group.full",
|
||||
{
|
||||
context: {
|
||||
groupId,
|
||||
peerId,
|
||||
capacity: group.capacity,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
group.members.add(peerId);
|
||||
this.bus.emit("group:membership", {
|
||||
groupId,
|
||||
peerId,
|
||||
change: "join",
|
||||
});
|
||||
}
|
||||
|
||||
public leave(groupId: string, peerId: string): boolean {
|
||||
const group = this.groups.get(groupId);
|
||||
if (group === undefined) return false;
|
||||
if (!group.members.delete(peerId)) return false;
|
||||
this.bus.emit("group:membership", {
|
||||
groupId,
|
||||
peerId,
|
||||
change: "leave",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public membersOf(groupId: string): readonly string[] {
|
||||
const group = this.groups.get(groupId);
|
||||
return group === undefined ? [] : [...group.members];
|
||||
}
|
||||
|
||||
public groupsOf(peerId: string): readonly string[] {
|
||||
const out: string[] = [];
|
||||
for (const [groupId, group] of this.groups) {
|
||||
if (group.members.has(peerId)) out.push(groupId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public list(): readonly Group[] {
|
||||
return [...this.groups.entries()].map(([groupId, group]) => ({
|
||||
groupId,
|
||||
capacity: group.capacity,
|
||||
members: new Set(group.members),
|
||||
}));
|
||||
}
|
||||
|
||||
public getCapacity(groupId: string): number | undefined {
|
||||
return this.groups.get(groupId)?.capacity;
|
||||
}
|
||||
|
||||
public size(groupId: string): number {
|
||||
return this.groups.get(groupId)?.members.size ?? 0;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.groups.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { type GroupBus, GroupManager } from "./group-manager.js";
|
||||
|
||||
function makeBus(): GroupBus & {
|
||||
events: Array<{
|
||||
groupId: string;
|
||||
peerId: string;
|
||||
change: "join" | "leave";
|
||||
}>;
|
||||
} {
|
||||
const events: Array<{
|
||||
groupId: string;
|
||||
peerId: string;
|
||||
change: "join" | "leave";
|
||||
}> = [];
|
||||
return {
|
||||
events,
|
||||
emit(_e, payload): void {
|
||||
events.push(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("GroupManager", () => {
|
||||
it("defines groups and tracks capacity", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
m.defineGroup("hosts", 4);
|
||||
expect(m.getCapacity("hosts")).toBe(4);
|
||||
expect(m.size("hosts")).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects invalid capacities", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
expect(() => m.defineGroup("bad", 0)).toThrow("group.invalid-capacity");
|
||||
expect(() => m.defineGroup("bad", -1)).toThrow(
|
||||
"group.invalid-capacity"
|
||||
);
|
||||
expect(() => m.defineGroup("bad", 1.5)).toThrow(
|
||||
"group.invalid-capacity"
|
||||
);
|
||||
});
|
||||
|
||||
it("upserts capacity on duplicate defineGroup", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
m.defineGroup("hosts", 4);
|
||||
m.defineGroup("hosts", 8);
|
||||
expect(m.getCapacity("hosts")).toBe(8);
|
||||
});
|
||||
|
||||
it("join adds members and emits", () => {
|
||||
const bus = makeBus();
|
||||
const m = new GroupManager({ bus });
|
||||
m.defineGroup("hosts", 2);
|
||||
m.join("hosts", "alice");
|
||||
expect(m.membersOf("hosts")).toEqual(["alice"]);
|
||||
expect(bus.events.at(-1)).toEqual({
|
||||
groupId: "hosts",
|
||||
peerId: "alice",
|
||||
change: "join",
|
||||
});
|
||||
});
|
||||
|
||||
it("re-join is a no-op", () => {
|
||||
const bus = makeBus();
|
||||
const m = new GroupManager({ bus });
|
||||
m.defineGroup("hosts", 2);
|
||||
m.join("hosts", "alice");
|
||||
m.join("hosts", "alice");
|
||||
expect(bus.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects join on unknown group", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
expect(() => m.join("ghost", "alice")).toThrow("group.not-found");
|
||||
});
|
||||
|
||||
it("rejects join when group is full", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
m.defineGroup("hosts", 1);
|
||||
m.join("hosts", "alice");
|
||||
expect(() => m.join("hosts", "bob")).toThrow("group.full");
|
||||
});
|
||||
|
||||
it("leave removes a member and reports success", () => {
|
||||
const bus = makeBus();
|
||||
const m = new GroupManager({ bus });
|
||||
m.defineGroup("hosts", 2);
|
||||
m.join("hosts", "alice");
|
||||
expect(m.leave("hosts", "alice")).toBe(true);
|
||||
expect(m.leave("hosts", "alice")).toBe(false);
|
||||
expect(m.leave("ghost", "alice")).toBe(false);
|
||||
});
|
||||
|
||||
it("groupsOf reports every group a peer belongs to", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
m.defineGroup("hosts", 4);
|
||||
m.defineGroup("subhosts", 4);
|
||||
m.join("hosts", "alice");
|
||||
m.join("subhosts", "alice");
|
||||
expect(m.groupsOf("alice").sort()).toEqual(["hosts", "subhosts"]);
|
||||
});
|
||||
|
||||
it("list returns every group with member snapshots", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
m.defineGroup("hosts", 4);
|
||||
m.join("hosts", "alice");
|
||||
const all = m.list();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.members.has("alice")).toBe(true);
|
||||
});
|
||||
|
||||
it("removeGroup emits leave for every member", () => {
|
||||
const bus = makeBus();
|
||||
const m = new GroupManager({ bus });
|
||||
m.defineGroup("hosts", 4);
|
||||
m.join("hosts", "alice");
|
||||
m.join("hosts", "bob");
|
||||
const before = bus.events.length;
|
||||
m.removeGroup("hosts");
|
||||
const leaveEvents = bus.events
|
||||
.slice(before)
|
||||
.filter((e) => e.change === "leave");
|
||||
expect(leaveEvents).toHaveLength(2);
|
||||
expect(m.getCapacity("hosts")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removeGroup on unknown group is a no-op", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
expect(() => m.removeGroup("ghost")).not.toThrow();
|
||||
});
|
||||
|
||||
it("clear empties everything", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
m.defineGroup("hosts", 4);
|
||||
m.join("hosts", "alice");
|
||||
m.clear();
|
||||
expect(m.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty list / undefined size for unknown groups", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
expect(m.membersOf("ghost")).toEqual([]);
|
||||
expect(m.size("ghost")).toBe(0);
|
||||
});
|
||||
|
||||
it("groupsOf skips groups the peer is not in", () => {
|
||||
const m = new GroupManager({ bus: makeBus() });
|
||||
m.defineGroup("hosts", 4);
|
||||
m.defineGroup("subhosts", 4);
|
||||
m.defineGroup("leaves", 4);
|
||||
m.join("hosts", "alice");
|
||||
m.join("leaves", "bob");
|
||||
// alice is only in hosts — subhosts and leaves both must be skipped
|
||||
expect(m.groupsOf("alice")).toEqual(["hosts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Error codes (side-effect registration)
|
||||
import "./error-codes.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
NETWORK_TOPO_CODES,
|
||||
NetworkTopoError,
|
||||
type NetworkTopoErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Group manager
|
||||
export {
|
||||
type Group,
|
||||
type GroupBus,
|
||||
GroupManager,
|
||||
type GroupManagerConfig,
|
||||
} from "./group/group-manager.js";
|
||||
// Latency assigner
|
||||
export {
|
||||
LatencyAssigner,
|
||||
type LatencyAssignerConfig,
|
||||
type LatencyBucket,
|
||||
type LatencyBus,
|
||||
} from "./latency/latency-assigner.js";
|
||||
// Plugin
|
||||
export {
|
||||
TopologyPlugin,
|
||||
type TopologyPluginConfig,
|
||||
} from "./plugin/topology-plugin.js";
|
||||
// Topology
|
||||
export {
|
||||
HierarchicalTopology,
|
||||
type HierarchicalTopologyConfig,
|
||||
type Tier,
|
||||
type TopologyBus,
|
||||
type TopologyNode,
|
||||
} from "./topology/hierarchical-topology.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# LatencyAssigner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Assigns peers to a latency bucket given their measured RTT and publishes `latency:rebalance` whenever a peer transitions between buckets. Bucket boundaries, names, and the overflow label are all operator-supplied — the assigner makes no assumption about what "fast" or "slow" means for any particular deployment.
|
||||
|
||||
## Features
|
||||
|
||||
- Operator-supplied bucket list with explicit ascending validation; misconfigured buckets throw at construction time.
|
||||
- Operator-supplied `overflowLabel` for latencies above every bucket; the assigner never defaults to a name on the operator's behalf.
|
||||
- `record(peerId, latencyMs)` returns the assigned label and emits `latency:rebalance` only on bucket changes (no churn for jitter inside one bucket).
|
||||
- `bucketFor(latencyMs)` is a pure classifier — no side effects, useful for "what-if" scoring in `network-plugin-assignment`.
|
||||
- `getBucket` / `listInBucket` / `bucketLabels` / `forget` / `clear` for observability and lifecycle.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LatencyAssigner } from "@suaveplan/network-plugin-topology";
|
||||
|
||||
const assigner = new LatencyAssigner({
|
||||
bus,
|
||||
buckets: [
|
||||
{ label: "local", maxLatencyMs: 30 },
|
||||
{ label: "regional", maxLatencyMs: 100 },
|
||||
{ label: "continental", maxLatencyMs: 250 },
|
||||
],
|
||||
overflowLabel: "global",
|
||||
});
|
||||
|
||||
assigner.record("alice", 12); // → "local"
|
||||
assigner.record("alice", 80); // → "regional", fires latency:rebalance
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LatencyAssigner } from "@suaveplan/network-plugin-topology";
|
||||
|
||||
const assigner = new LatencyAssigner({
|
||||
bus,
|
||||
buckets: [
|
||||
{ label: "rack", maxLatencyMs: 1 },
|
||||
{ label: "row", maxLatencyMs: 5 },
|
||||
{ label: "dc", maxLatencyMs: 25 },
|
||||
{ label: "region", maxLatencyMs: 80 },
|
||||
],
|
||||
overflowLabel: "wan",
|
||||
});
|
||||
|
||||
bus.on("peer:latency", ({ peerId, rttMs }) => {
|
||||
assigner.record(peerId, rttMs);
|
||||
});
|
||||
|
||||
const wanPeers = assigner.listInBucket("wan");
|
||||
metrics.gauge("topology.wan_peers", wanPeers.length);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface LatencyBucket { label, maxLatencyMs }`.
|
||||
- `interface LatencyBus { emit("latency:rebalance", { peerId, from, to, latencyMs }) }`.
|
||||
- `interface LatencyAssignerConfig { bus, buckets, overflowLabel }` — every field required.
|
||||
- `bucketFor(latencyMs): string` — pure classifier.
|
||||
- `record(peerId, latencyMs): string` — gated emit.
|
||||
- `getBucket(peerId): string | undefined`.
|
||||
- `listInBucket(label): readonly string[]`.
|
||||
- `bucketLabels(): readonly string[]` — every configured bucket plus the overflow label.
|
||||
- `forget(peerId)` / `clear()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The assigner validates ascending order at construction (rather than runtime) so a misconfigured bucket list surfaces as a constructor exception rather than as quietly-wrong classification across many records. `record` only emits on transition because measurement noise produces frequent micro-changes inside a bucket; downstream subscribers (the topology plugin, observability dashboards) want stable signals, not jitter. The overflow label is required (no default) because operators that intend "treat everything above X as one bucket" should name that bucket explicitly — silent fallback to `"unknown"` or similar would mask a configuration gap.
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* One bucket in the latency-bucket configuration. The bucket label is
|
||||
* operator-supplied so callers can use any classification taxonomy
|
||||
* (`"local" | "regional" | "global"`, geographic codes, RTT-bin names,
|
||||
* anything). The bucket matches when `latencyMs <= maxLatencyMs`.
|
||||
*/
|
||||
export interface LatencyBucket {
|
||||
readonly label: string;
|
||||
readonly maxLatencyMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bus shape — `LatencyAssigner` publishes `latency:rebalance` whenever
|
||||
* a peer's bucket changes.
|
||||
*/
|
||||
export interface LatencyBus {
|
||||
emit(
|
||||
event: "latency:rebalance",
|
||||
payload: {
|
||||
peerId: string;
|
||||
from: string | null;
|
||||
to: string;
|
||||
latencyMs: number;
|
||||
}
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `LatencyAssigner`. Buckets are required and fully
|
||||
* operator-supplied — there are no built-in latency thresholds.
|
||||
*
|
||||
* - `buckets` MUST be non-empty and sorted ascending by `maxLatencyMs`.
|
||||
* - `overflowLabel` MUST be supplied so latencies above the highest
|
||||
* bucket's threshold have a deterministic destination; the assigner
|
||||
* never picks a default name on the operator's behalf.
|
||||
*/
|
||||
export interface LatencyAssignerConfig {
|
||||
readonly bus: LatencyBus;
|
||||
readonly buckets: readonly LatencyBucket[];
|
||||
readonly overflowLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns peers to a latency bucket given their measured RTT. The
|
||||
* bucket boundaries, names, and overflow label are all operator-supplied
|
||||
* — the assigner has no opinion about what "fast" or "slow" means for
|
||||
* any particular deployment.
|
||||
*/
|
||||
export class LatencyAssigner {
|
||||
private readonly bus: LatencyBus;
|
||||
private readonly buckets: readonly LatencyBucket[];
|
||||
private readonly overflowLabel: string;
|
||||
private readonly current = new Map<string, string>();
|
||||
|
||||
public constructor(config: LatencyAssignerConfig) {
|
||||
if (config.buckets.length === 0) {
|
||||
throw new Error(
|
||||
"LatencyAssigner: buckets must contain at least one entry"
|
||||
);
|
||||
}
|
||||
// Validate ascending order so consumers see misconfiguration
|
||||
// immediately rather than as silent misclassification.
|
||||
for (let i = 1; i < config.buckets.length; i++) {
|
||||
if (
|
||||
config.buckets[i]!.maxLatencyMs <
|
||||
config.buckets[i - 1]!.maxLatencyMs
|
||||
) {
|
||||
throw new Error(
|
||||
"LatencyAssigner: buckets must be sorted ascending by maxLatencyMs"
|
||||
);
|
||||
}
|
||||
}
|
||||
this.bus = config.bus;
|
||||
this.buckets = config.buckets;
|
||||
this.overflowLabel = config.overflowLabel;
|
||||
}
|
||||
|
||||
public bucketFor(latencyMs: number): string {
|
||||
for (const b of this.buckets) {
|
||||
if (latencyMs <= b.maxLatencyMs) return b.label;
|
||||
}
|
||||
return this.overflowLabel;
|
||||
}
|
||||
|
||||
public record(peerId: string, latencyMs: number): string {
|
||||
const label = this.bucketFor(latencyMs);
|
||||
const previous = this.current.get(peerId) ?? null;
|
||||
if (previous !== label) {
|
||||
this.current.set(peerId, label);
|
||||
this.bus.emit("latency:rebalance", {
|
||||
peerId,
|
||||
from: previous,
|
||||
to: label,
|
||||
latencyMs,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
public getBucket(peerId: string): string | undefined {
|
||||
return this.current.get(peerId);
|
||||
}
|
||||
|
||||
public listInBucket(label: string): readonly string[] {
|
||||
const out: string[] = [];
|
||||
for (const [peerId, l] of this.current) {
|
||||
if (l === label) out.push(peerId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public bucketLabels(): readonly string[] {
|
||||
return [...this.buckets.map((b) => b.label), this.overflowLabel];
|
||||
}
|
||||
|
||||
public forget(peerId: string): void {
|
||||
this.current.delete(peerId);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.current.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
LatencyAssigner,
|
||||
type LatencyBucket,
|
||||
type LatencyBus,
|
||||
} from "./latency-assigner.js";
|
||||
|
||||
function makeBus(): LatencyBus & {
|
||||
events: Array<{
|
||||
peerId: string;
|
||||
from: string | null;
|
||||
to: string;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
} {
|
||||
const events: Array<{
|
||||
peerId: string;
|
||||
from: string | null;
|
||||
to: string;
|
||||
latencyMs: number;
|
||||
}> = [];
|
||||
return {
|
||||
events,
|
||||
emit(_e, payload): void {
|
||||
events.push(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const buckets: LatencyBucket[] = [
|
||||
{ label: "local", maxLatencyMs: 30 },
|
||||
{ label: "regional", maxLatencyMs: 100 },
|
||||
{ label: "continental", maxLatencyMs: 250 },
|
||||
];
|
||||
|
||||
describe("LatencyAssigner", () => {
|
||||
it("classifies into the first matching bucket", () => {
|
||||
const a = new LatencyAssigner({
|
||||
bus: makeBus(),
|
||||
buckets,
|
||||
overflowLabel: "global",
|
||||
});
|
||||
expect(a.bucketFor(10)).toBe("local");
|
||||
expect(a.bucketFor(50)).toBe("regional");
|
||||
expect(a.bucketFor(200)).toBe("continental");
|
||||
expect(a.bucketFor(500)).toBe("global");
|
||||
});
|
||||
|
||||
it("emits latency:rebalance only on bucket change", () => {
|
||||
const bus = makeBus();
|
||||
const a = new LatencyAssigner({
|
||||
bus,
|
||||
buckets,
|
||||
overflowLabel: "global",
|
||||
});
|
||||
expect(a.record("alice", 10)).toBe("local");
|
||||
expect(a.record("alice", 12)).toBe("local");
|
||||
expect(bus.events).toHaveLength(1);
|
||||
expect(bus.events[0]?.from).toBeNull();
|
||||
expect(bus.events[0]?.to).toBe("local");
|
||||
expect(a.record("alice", 80)).toBe("regional");
|
||||
expect(bus.events).toHaveLength(2);
|
||||
expect(bus.events[1]?.from).toBe("local");
|
||||
});
|
||||
|
||||
it("getBucket returns the last assigned label or undefined", () => {
|
||||
const a = new LatencyAssigner({
|
||||
bus: makeBus(),
|
||||
buckets,
|
||||
overflowLabel: "global",
|
||||
});
|
||||
expect(a.getBucket("ghost")).toBeUndefined();
|
||||
a.record("alice", 10);
|
||||
expect(a.getBucket("alice")).toBe("local");
|
||||
});
|
||||
|
||||
it("listInBucket filters by label", () => {
|
||||
const a = new LatencyAssigner({
|
||||
bus: makeBus(),
|
||||
buckets,
|
||||
overflowLabel: "global",
|
||||
});
|
||||
a.record("alice", 10);
|
||||
a.record("bob", 12);
|
||||
a.record("carol", 80);
|
||||
expect(a.listInBucket("local").sort()).toEqual(["alice", "bob"]);
|
||||
expect(a.listInBucket("regional")).toEqual(["carol"]);
|
||||
expect(a.listInBucket("global")).toEqual([]);
|
||||
});
|
||||
|
||||
it("bucketLabels returns every label including overflow", () => {
|
||||
const a = new LatencyAssigner({
|
||||
bus: makeBus(),
|
||||
buckets,
|
||||
overflowLabel: "global",
|
||||
});
|
||||
expect(a.bucketLabels()).toEqual([
|
||||
"local",
|
||||
"regional",
|
||||
"continental",
|
||||
"global",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forget drops one peer", () => {
|
||||
const a = new LatencyAssigner({
|
||||
bus: makeBus(),
|
||||
buckets,
|
||||
overflowLabel: "global",
|
||||
});
|
||||
a.record("alice", 10);
|
||||
a.forget("alice");
|
||||
expect(a.getBucket("alice")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clear empties everything", () => {
|
||||
const a = new LatencyAssigner({
|
||||
bus: makeBus(),
|
||||
buckets,
|
||||
overflowLabel: "global",
|
||||
});
|
||||
a.record("alice", 10);
|
||||
a.record("bob", 80);
|
||||
a.clear();
|
||||
expect(a.listInBucket("local")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects empty buckets at construction", () => {
|
||||
expect(
|
||||
() =>
|
||||
new LatencyAssigner({
|
||||
bus: makeBus(),
|
||||
buckets: [],
|
||||
overflowLabel: "global",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unsorted buckets at construction", () => {
|
||||
expect(
|
||||
() =>
|
||||
new LatencyAssigner({
|
||||
bus: makeBus(),
|
||||
buckets: [
|
||||
{ label: "high", maxLatencyMs: 100 },
|
||||
{ label: "low", maxLatencyMs: 10 },
|
||||
],
|
||||
overflowLabel: "global",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
# TopologyPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`NetworkPlugin` that owns the topology, latency-assigner, and group manager and wires them to the node's bus. Subscribes to `election:result` and `peer:latency` so upstream signals automatically drive topology promotions and bucket reassignments — the consuming application does not have to write any glue between the election engine and the topology.
|
||||
|
||||
## Features
|
||||
|
||||
- All three sub-managers are fully operator-configurable; if `latency` config is omitted, the plugin runs with no latency bucketing (some deployments don't need it).
|
||||
- `topology.bus` / `latency.bus` / `group.bus` are filled in automatically from the host node's `networkBus` so config doesn't have to repeat the bus three times.
|
||||
- Election winners auto-register at tier 0 (or are promoted to it) — operators can opt out by not subscribing to `election:result` (the plugin still installs the listener; consumers that need a different policy should configure their own bus topic).
|
||||
- `peer:latency` events automatically feed `LatencyAssigner.record`.
|
||||
- `dispose()` clears every sub-manager and nulls them out so a re-install starts fresh.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TopologyPlugin } from "@suaveplan/network-plugin-topology";
|
||||
|
||||
const plugin = new TopologyPlugin({
|
||||
topology: { maxDepth: 3 },
|
||||
latency: {
|
||||
buckets: [
|
||||
{ label: "local", maxLatencyMs: 30 },
|
||||
{ label: "regional", maxLatencyMs: 100 },
|
||||
],
|
||||
overflowLabel: "global",
|
||||
},
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TopologyPlugin } from "@suaveplan/network-plugin-topology";
|
||||
|
||||
const plugin = new TopologyPlugin({
|
||||
topology: {
|
||||
maxDepth: 5,
|
||||
tierLabels: ["RootHost", "Region", "Cluster", "Group", "Leaf"],
|
||||
},
|
||||
latency: {
|
||||
buckets: [
|
||||
{ label: "rack", maxLatencyMs: 1 },
|
||||
{ label: "row", maxLatencyMs: 5 },
|
||||
{ label: "dc", maxLatencyMs: 25 },
|
||||
{ label: "region", maxLatencyMs: 80 },
|
||||
],
|
||||
overflowLabel: "wan",
|
||||
},
|
||||
});
|
||||
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
|
||||
// downstream subsystems can manipulate the managers directly
|
||||
plugin.group?.defineGroup("hosts", 4);
|
||||
plugin.topology?.register("alice", 4);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TopologyPluginConfig { topology?, latency?, group? }` — every sub-section optional; bus fields are auto-filled.
|
||||
- Public properties: `topology: HierarchicalTopology | null`, `latency: LatencyAssigner | null`, `group: GroupManager | null` (all populated after `install`).
|
||||
- `name: "topology"`.
|
||||
- `install(node)` / `start()` / `stop()` / `dispose()` — standard `NetworkPlugin` lifecycle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bus type is widened with `as never` because `NetworkBus` is generically typed and the plugin's topics (`topology:change`, `latency:rebalance`, `group:membership`, `election:result`, `peer:latency`) are not in its compile-time map. Sub-managers are typed nullable because they only exist after `install` runs — callers that need them before install must construct a fresh plugin instance. The two listeners are intentionally defensive: a malformed payload is silently dropped rather than throwing, because bus traffic from other plugins should not be able to crash the topology plugin via type confusion.
|
||||
@@ -0,0 +1,177 @@
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TopologyPlugin } from "./topology-plugin.js";
|
||||
|
||||
describe("TopologyPlugin", () => {
|
||||
it("exposes the canonical name", () => {
|
||||
const p = new TopologyPlugin();
|
||||
expect(p.name).toBe("topology");
|
||||
});
|
||||
|
||||
it("install builds topology and group managers (latency optional)", () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin();
|
||||
node.use(plugin);
|
||||
expect(plugin.topology).not.toBeNull();
|
||||
expect(plugin.group).not.toBeNull();
|
||||
expect(plugin.latency).toBeNull();
|
||||
});
|
||||
|
||||
it("install builds latency assigner when configured", () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin({
|
||||
latency: {
|
||||
buckets: [{ label: "fast", maxLatencyMs: 50 }],
|
||||
overflowLabel: "slow",
|
||||
},
|
||||
});
|
||||
node.use(plugin);
|
||||
expect(plugin.latency).not.toBeNull();
|
||||
});
|
||||
|
||||
it("election:result registers a new winner at tier 0", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin({
|
||||
topology: { maxDepth: 3 },
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
node.networkBus.emit("election:result", {
|
||||
mode: "metric",
|
||||
winnerId: "alice",
|
||||
score: 1,
|
||||
term: 1,
|
||||
});
|
||||
expect(plugin.topology?.getTier("alice")).toBe(0);
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("election:result promotes existing peer to tier 0", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin({
|
||||
topology: { maxDepth: 3 },
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
plugin.topology?.register("alice", 2);
|
||||
node.networkBus.emit("election:result", {
|
||||
mode: "metric",
|
||||
winnerId: "alice",
|
||||
score: 1,
|
||||
term: 2,
|
||||
});
|
||||
expect(plugin.topology?.getTier("alice")).toBe(0);
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("election:result is a no-op when winner is already at tier 0", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
plugin.topology?.register("alice", 0);
|
||||
node.networkBus.emit("election:result", {
|
||||
mode: "metric",
|
||||
winnerId: "alice",
|
||||
score: 1,
|
||||
term: 3,
|
||||
});
|
||||
expect(plugin.topology?.getTier("alice")).toBe(0);
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("malformed election:result payloads are silently dropped", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
node.networkBus.emit("election:result", null);
|
||||
node.networkBus.emit("election:result", { winnerId: "x" }); // missing term
|
||||
node.networkBus.emit("election:result", "not-an-object");
|
||||
expect(plugin.topology?.size()).toBe(0);
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("peer:latency feeds the latency assigner", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin({
|
||||
latency: {
|
||||
buckets: [{ label: "fast", maxLatencyMs: 50 }],
|
||||
overflowLabel: "slow",
|
||||
},
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
node.networkBus.emit("peer:latency", {
|
||||
peerId: "alice",
|
||||
rttMs: 10,
|
||||
});
|
||||
expect(plugin.latency?.getBucket("alice")).toBe("fast");
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("malformed peer:latency payloads are silently dropped", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin({
|
||||
latency: {
|
||||
buckets: [{ label: "fast", maxLatencyMs: 50 }],
|
||||
overflowLabel: "slow",
|
||||
},
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
node.networkBus.emit("peer:latency", null);
|
||||
node.networkBus.emit("peer:latency", { peerId: "x" }); // missing rttMs
|
||||
expect(plugin.latency?.getBucket("x")).toBeUndefined();
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("dispose clears every sub-manager", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin({
|
||||
latency: {
|
||||
buckets: [{ label: "fast", maxLatencyMs: 50 }],
|
||||
overflowLabel: "slow",
|
||||
},
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
plugin.topology?.register("alice", 0);
|
||||
plugin.dispose();
|
||||
expect(plugin.topology).toBeNull();
|
||||
expect(plugin.latency).toBeNull();
|
||||
expect(plugin.group).toBeNull();
|
||||
});
|
||||
|
||||
it("stop unsubscribes bus listeners", async () => {
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
await node.stop();
|
||||
// After stop, bus events should not register peers
|
||||
node.networkBus.emit("election:result", {
|
||||
mode: "metric",
|
||||
winnerId: "ghost",
|
||||
score: 1,
|
||||
term: 99,
|
||||
});
|
||||
expect(plugin.topology?.getTier("ghost")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("election:result is a no-op after dispose", () => {
|
||||
// dispose without stop — listeners are still wired but topology
|
||||
// is null, so the early-return guard fires.
|
||||
const node = new NetworkNode();
|
||||
const plugin = new TopologyPlugin();
|
||||
node.use(plugin);
|
||||
plugin.dispose();
|
||||
node.networkBus.emit("election:result", {
|
||||
mode: "metric",
|
||||
winnerId: "alice",
|
||||
score: 1,
|
||||
term: 1,
|
||||
});
|
||||
expect(plugin.topology).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
|
||||
import {
|
||||
GroupManager,
|
||||
type GroupManagerConfig,
|
||||
} from "../group/group-manager.js";
|
||||
import {
|
||||
LatencyAssigner,
|
||||
type LatencyAssignerConfig,
|
||||
} from "../latency/latency-assigner.js";
|
||||
import {
|
||||
HierarchicalTopology,
|
||||
type HierarchicalTopologyConfig,
|
||||
} from "../topology/hierarchical-topology.js";
|
||||
|
||||
/**
|
||||
* Configuration for `TopologyPlugin`.
|
||||
*
|
||||
* Every sub-component is constructed from operator-supplied config —
|
||||
* the plugin neither invents bucket boundaries nor imposes a default
|
||||
* tier count. `topology.bus` / `latency.bus` / `group.bus` are filled
|
||||
* in by the plugin from the host node's `networkBus` at install time
|
||||
* so callers do not need to supply them three times.
|
||||
*/
|
||||
export interface TopologyPluginConfig {
|
||||
readonly topology?: Omit<HierarchicalTopologyConfig, "bus">;
|
||||
readonly latency?: Omit<LatencyAssignerConfig, "bus">;
|
||||
readonly group?: Omit<GroupManagerConfig, "bus">;
|
||||
}
|
||||
|
||||
/**
|
||||
* `NetworkPlugin` that owns the topology / latency / group managers
|
||||
* and wires them to the node's bus. The plugin subscribes to
|
||||
* `election:result` and `peer:latency` so upstream signals
|
||||
* automatically drive topology promotions and bucket reassignments
|
||||
* without bespoke glue at the call site.
|
||||
*/
|
||||
export class TopologyPlugin implements NetworkPlugin {
|
||||
public readonly name = "topology";
|
||||
public topology: HierarchicalTopology | null = null;
|
||||
public latency: LatencyAssigner | null = null;
|
||||
public group: GroupManager | null = null;
|
||||
private readonly config: TopologyPluginConfig;
|
||||
private readonly unsubscribes: Array<() => void> = [];
|
||||
|
||||
public constructor(config: TopologyPluginConfig = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public install(node: NetworkNode): void {
|
||||
const bus = node.networkBus as never;
|
||||
this.topology = new HierarchicalTopology({
|
||||
...(this.config.topology ?? {}),
|
||||
bus,
|
||||
});
|
||||
if (this.config.latency !== undefined) {
|
||||
this.latency = new LatencyAssigner({
|
||||
...this.config.latency,
|
||||
bus,
|
||||
});
|
||||
}
|
||||
this.group = new GroupManager({
|
||||
...(this.config.group ?? {}),
|
||||
bus,
|
||||
});
|
||||
|
||||
this.unsubscribes.push(
|
||||
node.networkBus.on("election:result", (payload: unknown) => {
|
||||
if (
|
||||
payload === null ||
|
||||
typeof payload !== "object" ||
|
||||
!("winnerId" in payload) ||
|
||||
!("term" in payload)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { winnerId } = payload as { winnerId: string };
|
||||
if (this.topology === null) return;
|
||||
const current = this.topology.getTier(winnerId);
|
||||
if (current === undefined) {
|
||||
this.topology.register(winnerId, 0, "election:result");
|
||||
return;
|
||||
}
|
||||
if (current === 0) return;
|
||||
this.topology.promote(winnerId, 0, "election:result");
|
||||
})
|
||||
);
|
||||
this.unsubscribes.push(
|
||||
node.networkBus.on("peer:latency", (payload: unknown) => {
|
||||
if (
|
||||
payload === null ||
|
||||
typeof payload !== "object" ||
|
||||
!("peerId" in payload) ||
|
||||
!("rttMs" in payload)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { peerId, rttMs } = payload as {
|
||||
peerId: string;
|
||||
rttMs: number;
|
||||
};
|
||||
this.latency?.record(peerId, rttMs);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// sub-managers are already wired in install
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
for (const off of this.unsubscribes) off();
|
||||
this.unsubscribes.length = 0;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.topology?.clear();
|
||||
this.latency?.clear();
|
||||
this.group?.clear();
|
||||
this.topology = null;
|
||||
this.latency = null;
|
||||
this.group = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# HierarchicalTopology
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
N-tier mesh topology. Tracks each peer's current tier (a non-negative integer where `0` is the root and higher numbers are deeper), validates promote / demote moves, and publishes `topology:change` on every transition. The class has no compile-time tier ceiling and no built-in tier names — the structure is entirely operator-driven.
|
||||
|
||||
## Features
|
||||
|
||||
- Optional `maxDepth` upper bound. When supplied, tiers must be in `[0, maxDepth)`; when omitted, any non-negative integer is accepted, so the topology can grow without re-instantiation as the mesh scales.
|
||||
- Optional `tierLabels` for dashboards / logging. When omitted, `tierLabel(n)` returns `"Tier<n>"` — there are no built-in labels (no implicit `"Host" / "SubHost" / "Leaf"` assumption).
|
||||
- `register` / `unregister` / `getTier` / `listAtTier` / `list` / `sizeAtTier` / `size` for ordinary lifecycle.
|
||||
- `observedMaxTier()` reports the deepest currently-occupied tier so the consumer can grow or shrink supporting subsystems (latency buckets, group manager) without a hard-coded depth.
|
||||
- `promote(peerId, toTier)` and `demote(peerId, toTier)` enforce direction (promote must move up, demote must move down) and surface `INVALID_TIER` / `PEER_NOT_FOUND` / `ALREADY_AT_TIER` distinctly.
|
||||
- Every successful state change emits `topology:change` with `from` / `to` / `reason` so downstream observers (election engine, latency assigner, group manager) can react.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HierarchicalTopology } from "@suaveplan/network-plugin-topology";
|
||||
|
||||
// no maxDepth, no labels — accept any non-negative tier
|
||||
const topo = new HierarchicalTopology({ bus });
|
||||
topo.register("alice", 2);
|
||||
topo.promote("alice", 1, "election:result");
|
||||
topo.promote("alice", 0, "host-vacancy");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HierarchicalTopology } from "@suaveplan/network-plugin-topology";
|
||||
|
||||
// Five-tier deployment with operator-supplied labels.
|
||||
const topo = new HierarchicalTopology({
|
||||
bus,
|
||||
maxDepth: 5,
|
||||
tierLabels: ["RootHost", "Region", "Cluster", "Group", "Leaf"],
|
||||
});
|
||||
|
||||
topo.register("alice", 4);
|
||||
topo.register("bob", 3);
|
||||
|
||||
const deepest = topo.observedMaxTier(); // 4
|
||||
const leaves = topo.listAtTier(deepest!);
|
||||
for (const peerId of leaves.slice(0, 2)) {
|
||||
topo.promote(peerId, deepest! - 1, "rebalance");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type Tier = number` — `0` is root.
|
||||
- `interface TopologyNode { peerId, tier }`.
|
||||
- `interface TopologyBus { emit("topology:change", { peerId, from, to, reason }) }`.
|
||||
- `interface HierarchicalTopologyConfig { bus, maxDepth?, tierLabels? }` — both bounds and labels are optional and operator-supplied.
|
||||
- `getMaxDepth(): number | undefined`.
|
||||
- `tierLabel(tier): string` — returns the configured label or `"Tier<n>"`.
|
||||
- `register(peerId, tier, reason?)` / `unregister(peerId)`.
|
||||
- `getTier(peerId): Tier | undefined`.
|
||||
- `listAtTier(tier): readonly string[]` / `list(): readonly TopologyNode[]`.
|
||||
- `size(): number` / `sizeAtTier(tier): number` / `observedMaxTier(): Tier | undefined`.
|
||||
- `promote(peerId, toTier, reason?)` — `toTier` must be strictly less than current tier.
|
||||
- `demote(peerId, toTier, reason?)` — `toTier` must be strictly greater than current tier.
|
||||
- `clear()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`promote` and `demote` route through one private `move(direction)` helper so the direction-validation logic lives in exactly one place. Throwing `INVALID_TIER` for "promote that doesn't go up" is deliberate — a caller that intends a sideways move is almost certainly a bug, and silent acceptance would let the FSM drift over time. `register(peer, sameTier)` is a no-op (no event) so callers can re-register on every heartbeat without churning the bus. `unregister` does not emit a topology change either; consumers who want disconnection signals should subscribe to `peer:disconnected` on the bus directly. The `assertTier` helper splits its check into two distinct error contexts (`invalid-tier` for negative/non-integer, `tier-out-of-bounds` for over-`maxDepth`) so dashboards can tell "operator passed a bad value" apart from "mesh hit its configured ceiling".
|
||||
@@ -0,0 +1,212 @@
|
||||
import { NETWORK_TOPO_CODES, NetworkTopoError } from "../error-codes.js";
|
||||
|
||||
/**
|
||||
* A tier number. `0` is the root; higher numbers are deeper in the
|
||||
* hierarchy. The maximum depth is configurable per topology — this
|
||||
* plugin has no compile-time tier ceiling.
|
||||
*/
|
||||
export type Tier = number;
|
||||
|
||||
/**
|
||||
* Per-peer topology entry.
|
||||
*/
|
||||
export interface TopologyNode {
|
||||
readonly peerId: string;
|
||||
readonly tier: Tier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bus shape for topology events.
|
||||
*/
|
||||
export interface TopologyBus {
|
||||
emit(
|
||||
event: "topology:change",
|
||||
payload: {
|
||||
peerId: string;
|
||||
from: Tier | null;
|
||||
to: Tier;
|
||||
reason: string;
|
||||
}
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `HierarchicalTopology`.
|
||||
*
|
||||
* Every value is operator-supplied. There are no built-in tier counts,
|
||||
* tier names, or threshold defaults — the topology is fully dynamic.
|
||||
*
|
||||
* - `maxDepth` (optional): when present, tiers must be in `[0, maxDepth)`;
|
||||
* when omitted, any non-negative integer tier is accepted.
|
||||
* - `tierLabels` (optional): per-index human-readable name for
|
||||
* dashboards / logs. When omitted, `tierLabel(n)` returns `"Tier<n>"`.
|
||||
* The topology never branches on labels.
|
||||
*/
|
||||
export interface HierarchicalTopologyConfig {
|
||||
readonly bus: TopologyBus;
|
||||
readonly maxDepth?: number;
|
||||
readonly tierLabels?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* N-tier mesh topology. Tiers are non-negative integers; depth is
|
||||
* configurable and may be unbounded. The same primitive serves
|
||||
* 2-tier server-and-clients deployments, 3-tier Host/SubHost/Leaf
|
||||
* topologies, or any deeper hierarchy that grows or shrinks at
|
||||
* runtime — operators control the structure entirely through
|
||||
* `register` / `promote` / `demote` calls.
|
||||
*/
|
||||
export class HierarchicalTopology {
|
||||
private readonly bus: TopologyBus;
|
||||
private readonly maxDepth: number | undefined;
|
||||
private readonly labels: ReadonlyArray<string> | undefined;
|
||||
private readonly nodes = new Map<string, Tier>();
|
||||
|
||||
public constructor(config: HierarchicalTopologyConfig) {
|
||||
this.bus = config.bus;
|
||||
this.maxDepth = config.maxDepth;
|
||||
this.labels = config.tierLabels;
|
||||
}
|
||||
|
||||
public getMaxDepth(): number | undefined {
|
||||
return this.maxDepth;
|
||||
}
|
||||
|
||||
public tierLabel(tier: Tier): string {
|
||||
this.assertTier(tier);
|
||||
return this.labels?.[tier] ?? `Tier${tier}`;
|
||||
}
|
||||
|
||||
public register(peerId: string, tier: Tier, reason = "register"): void {
|
||||
this.assertTier(tier);
|
||||
const existing = this.nodes.get(peerId);
|
||||
if (existing === tier) return;
|
||||
this.nodes.set(peerId, tier);
|
||||
this.bus.emit("topology:change", {
|
||||
peerId,
|
||||
from: existing ?? null,
|
||||
to: tier,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
public unregister(peerId: string): void {
|
||||
this.nodes.delete(peerId);
|
||||
}
|
||||
|
||||
public getTier(peerId: string): Tier | undefined {
|
||||
return this.nodes.get(peerId);
|
||||
}
|
||||
|
||||
public listAtTier(tier: Tier): readonly string[] {
|
||||
this.assertTier(tier);
|
||||
const out: string[] = [];
|
||||
for (const [peerId, t] of this.nodes) {
|
||||
if (t === tier) out.push(peerId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public list(): readonly TopologyNode[] {
|
||||
return [...this.nodes.entries()].map(([peerId, tier]) => ({
|
||||
peerId,
|
||||
tier,
|
||||
}));
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.nodes.size;
|
||||
}
|
||||
|
||||
public sizeAtTier(tier: Tier): number {
|
||||
this.assertTier(tier);
|
||||
let n = 0;
|
||||
for (const t of this.nodes.values()) {
|
||||
if (t === tier) n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
public observedMaxTier(): Tier | undefined {
|
||||
if (this.nodes.size === 0) return undefined;
|
||||
let max = 0;
|
||||
for (const t of this.nodes.values()) {
|
||||
if (t > max) max = t;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
public promote(peerId: string, toTier: Tier, reason = "promote"): void {
|
||||
this.move(peerId, toTier, reason, "up");
|
||||
}
|
||||
|
||||
public demote(peerId: string, toTier: Tier, reason = "demote"): void {
|
||||
this.move(peerId, toTier, reason, "down");
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.nodes.clear();
|
||||
}
|
||||
|
||||
private move(
|
||||
peerId: string,
|
||||
toTier: Tier,
|
||||
reason: string,
|
||||
direction: "up" | "down"
|
||||
): void {
|
||||
this.assertTier(toTier);
|
||||
const from = this.nodes.get(peerId);
|
||||
if (from === undefined) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.PEER_NOT_FOUND,
|
||||
"topology.peer-not-found",
|
||||
{ context: { peerId } }
|
||||
);
|
||||
}
|
||||
if (from === toTier) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.ALREADY_AT_TIER,
|
||||
"topology.already-at-tier",
|
||||
{ context: { peerId, tier: from } }
|
||||
);
|
||||
}
|
||||
if (direction === "up" && toTier >= from) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.INVALID_TIER,
|
||||
"topology.promote-not-up",
|
||||
{ context: { peerId, from, toTier } }
|
||||
);
|
||||
}
|
||||
if (direction === "down" && toTier <= from) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.INVALID_TIER,
|
||||
"topology.demote-not-down",
|
||||
{ context: { peerId, from, toTier } }
|
||||
);
|
||||
}
|
||||
this.nodes.set(peerId, toTier);
|
||||
this.bus.emit("topology:change", {
|
||||
peerId,
|
||||
from,
|
||||
to: toTier,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
private assertTier(tier: Tier): void {
|
||||
if (!Number.isInteger(tier) || tier < 0) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.INVALID_TIER,
|
||||
"topology.invalid-tier",
|
||||
{ context: { tier } }
|
||||
);
|
||||
}
|
||||
if (this.maxDepth !== undefined && tier >= this.maxDepth) {
|
||||
throw new NetworkTopoError(
|
||||
NETWORK_TOPO_CODES.INVALID_TIER,
|
||||
"topology.tier-out-of-bounds",
|
||||
{ context: { tier, maxDepth: this.maxDepth } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HierarchicalTopology,
|
||||
type Tier,
|
||||
type TopologyBus,
|
||||
} from "./hierarchical-topology.js";
|
||||
|
||||
function makeBus(): TopologyBus & {
|
||||
events: Array<{
|
||||
peerId: string;
|
||||
from: Tier | null;
|
||||
to: Tier;
|
||||
reason: string;
|
||||
}>;
|
||||
} {
|
||||
const events: Array<{
|
||||
peerId: string;
|
||||
from: Tier | null;
|
||||
to: Tier;
|
||||
reason: string;
|
||||
}> = [];
|
||||
return {
|
||||
events,
|
||||
emit(_e, payload): void {
|
||||
events.push(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("HierarchicalTopology", () => {
|
||||
it("starts empty and reports unknown tier", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
expect(t.size()).toBe(0);
|
||||
expect(t.getTier("ghost")).toBeUndefined();
|
||||
expect(t.observedMaxTier()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers a peer and emits topology:change", () => {
|
||||
const bus = makeBus();
|
||||
const t = new HierarchicalTopology({ bus });
|
||||
t.register("alice", 2);
|
||||
expect(t.getTier("alice")).toBe(2);
|
||||
expect(bus.events).toHaveLength(1);
|
||||
expect(bus.events[0]?.from).toBeNull();
|
||||
expect(bus.events[0]?.to).toBe(2);
|
||||
});
|
||||
|
||||
it("re-register at the same tier is a no-op", () => {
|
||||
const bus = makeBus();
|
||||
const t = new HierarchicalTopology({ bus });
|
||||
t.register("alice", 2);
|
||||
t.register("alice", 2);
|
||||
expect(bus.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("re-register at a different tier emits a change", () => {
|
||||
const bus = makeBus();
|
||||
const t = new HierarchicalTopology({ bus });
|
||||
t.register("alice", 2);
|
||||
t.register("alice", 1, "rebalance");
|
||||
expect(bus.events).toHaveLength(2);
|
||||
expect(bus.events[1]?.from).toBe(2);
|
||||
expect(bus.events[1]?.reason).toBe("rebalance");
|
||||
});
|
||||
|
||||
it("rejects negative or non-integer tiers", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
expect(() => t.register("alice", -1)).toThrow("topology.invalid-tier");
|
||||
expect(() => t.register("alice", 1.5)).toThrow("topology.invalid-tier");
|
||||
});
|
||||
|
||||
it("rejects tiers >= maxDepth when configured", () => {
|
||||
const t = new HierarchicalTopology({
|
||||
bus: makeBus(),
|
||||
maxDepth: 3,
|
||||
});
|
||||
expect(() => t.register("alice", 3)).toThrow(
|
||||
"topology.tier-out-of-bounds"
|
||||
);
|
||||
// negative path on the same assertion
|
||||
expect(() => t.register("alice", -1)).toThrow("topology.invalid-tier");
|
||||
});
|
||||
|
||||
it("accepts arbitrarily deep tiers without maxDepth", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 99);
|
||||
expect(t.getTier("alice")).toBe(99);
|
||||
});
|
||||
|
||||
it("tierLabel returns operator label or fallback", () => {
|
||||
const t = new HierarchicalTopology({
|
||||
bus: makeBus(),
|
||||
tierLabels: ["Root", "Mid"],
|
||||
});
|
||||
expect(t.tierLabel(0)).toBe("Root");
|
||||
expect(t.tierLabel(1)).toBe("Mid");
|
||||
expect(t.tierLabel(5)).toBe("Tier5");
|
||||
});
|
||||
|
||||
it("tierLabel without configured labels uses Tier<n>", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
expect(t.tierLabel(0)).toBe("Tier0");
|
||||
expect(t.tierLabel(7)).toBe("Tier7");
|
||||
});
|
||||
|
||||
it("listAtTier and sizeAtTier filter by tier", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 0);
|
||||
t.register("bob", 1);
|
||||
t.register("carol", 1);
|
||||
expect(t.listAtTier(0)).toEqual(["alice"]);
|
||||
expect(t.sizeAtTier(1)).toBe(2);
|
||||
expect(t.listAtTier(2)).toEqual([]);
|
||||
});
|
||||
|
||||
it("list returns every node", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 0);
|
||||
t.register("bob", 2);
|
||||
const all = t.list().sort((a, b) => a.peerId.localeCompare(b.peerId));
|
||||
expect(all).toEqual([
|
||||
{ peerId: "alice", tier: 0 },
|
||||
{ peerId: "bob", tier: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("observedMaxTier returns the deepest occupied tier", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 0);
|
||||
t.register("bob", 5);
|
||||
t.register("carol", 3);
|
||||
expect(t.observedMaxTier()).toBe(5);
|
||||
});
|
||||
|
||||
it("promote moves up and emits", () => {
|
||||
const bus = makeBus();
|
||||
const t = new HierarchicalTopology({ bus });
|
||||
t.register("alice", 2);
|
||||
t.promote("alice", 1, "election");
|
||||
expect(t.getTier("alice")).toBe(1);
|
||||
expect(bus.events.at(-1)?.reason).toBe("election");
|
||||
});
|
||||
|
||||
it("demote moves down", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 0);
|
||||
t.demote("alice", 1, "rebalance");
|
||||
expect(t.getTier("alice")).toBe(1);
|
||||
});
|
||||
|
||||
it("promote rejects when not actually moving up", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 1);
|
||||
expect(() => t.promote("alice", 2)).toThrow("topology.promote-not-up");
|
||||
});
|
||||
|
||||
it("demote rejects when not actually moving down", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 1);
|
||||
expect(() => t.demote("alice", 0)).toThrow("topology.demote-not-down");
|
||||
});
|
||||
|
||||
it("promote/demote on unknown peer throws PEER_NOT_FOUND", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
expect(() => t.promote("ghost", 0)).toThrow("topology.peer-not-found");
|
||||
});
|
||||
|
||||
it("promote to current tier throws ALREADY_AT_TIER", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 1);
|
||||
expect(() => t.promote("alice", 1)).toThrow("topology.already-at-tier");
|
||||
});
|
||||
|
||||
it("unregister removes the peer without emitting a change", () => {
|
||||
const bus = makeBus();
|
||||
const t = new HierarchicalTopology({ bus });
|
||||
t.register("alice", 0);
|
||||
const before = bus.events.length;
|
||||
t.unregister("alice");
|
||||
expect(t.getTier("alice")).toBeUndefined();
|
||||
expect(bus.events.length).toBe(before);
|
||||
});
|
||||
|
||||
it("clear empties the topology", () => {
|
||||
const t = new HierarchicalTopology({ bus: makeBus() });
|
||||
t.register("alice", 0);
|
||||
t.register("bob", 1);
|
||||
t.clear();
|
||||
expect(t.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("getMaxDepth returns the configured value or undefined", () => {
|
||||
expect(
|
||||
new HierarchicalTopology({ bus: makeBus() }).getMaxDepth()
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
new HierarchicalTopology({
|
||||
bus: makeBus(),
|
||||
maxDepth: 4,
|
||||
}).getMaxDepth()
|
||||
).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-plugin-topology dep-graph", () => {
|
||||
it("loads index.js (acyclic static imports)", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod.HierarchicalTopology).toBeDefined();
|
||||
expect(mod.LatencyAssigner).toBeDefined();
|
||||
expect(mod.GroupManager).toBeDefined();
|
||||
expect(mod.TopologyPlugin).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"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir:
|
||||
"../../../node_modules/.vite/packages/network-plugin-topology",
|
||||
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/network",
|
||||
"@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