Initial Commit
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# @suaveplan/network-plugin-assignment - Mesh auto-assignment and load balancing: capacity tracker, assignment-scoring engine, rebalance planner, admission controller
|
||||
|
||||
## Summary
|
||||
|
||||
Mesh auto-assignment and load balancing: live `MeshCapacityTracker`, weighted-multi-criteria `AssignmentEngine`, imbalance-detecting `MeshBalancer` planner, and admission-gating `AdmissionController`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
|
||||
<!-- end:auto-docs --> - [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
|
||||
- [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
|
||||
- [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
|
||||
- [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_ - [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-assignment",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Mesh auto-assignment and load balancing: capacity tracker, assignment-scoring engine, rebalance planner, admission controller",
|
||||
"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",
|
||||
"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,114 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MeshCapacityTracker } from "../tracker/mesh-capacity-tracker.js";
|
||||
import { MeshBalancer } from "./mesh-balancer.js";
|
||||
|
||||
describe("MeshBalancer", () => {
|
||||
it("computeImbalance is 0 when fewer than 2 meshes", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
const b = new MeshBalancer({ tracker: t });
|
||||
expect(b.computeImbalance()).toBe(0);
|
||||
t.recordMesh("mesh-A");
|
||||
expect(b.computeImbalance()).toBe(0);
|
||||
});
|
||||
|
||||
it("computeImbalance reflects max-min spread", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 10);
|
||||
t.recordMesh("mesh-B", 10);
|
||||
for (let i = 0; i < 8; i++) t.recordJoin("mesh-A", `p${i}`);
|
||||
const b = new MeshBalancer({ tracker: t });
|
||||
expect(b.computeImbalance()).toBeCloseTo(0.8, 5);
|
||||
});
|
||||
|
||||
it("computeImbalance handles 3+ meshes", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 10);
|
||||
t.recordMesh("mesh-B", 10);
|
||||
t.recordMesh("mesh-C", 10);
|
||||
for (let i = 0; i < 9; i++) t.recordJoin("mesh-A", `pA${i}`);
|
||||
for (let i = 0; i < 5; i++) t.recordJoin("mesh-B", `pB${i}`);
|
||||
const b = new MeshBalancer({ tracker: t });
|
||||
// max=0.9, min=0 (mesh-C empty), mesh-B in the middle
|
||||
expect(b.computeImbalance()).toBeCloseTo(0.9, 5);
|
||||
});
|
||||
|
||||
it("computeImbalance handles ascending-utilisation iteration", () => {
|
||||
// Insertion order [empty, half, full] forces the loop to hit the
|
||||
// `u < min` *false* branch on the second and third iterations.
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 10); // 0/10 — least full
|
||||
t.recordMesh("mesh-B", 10); // 5/10
|
||||
t.recordMesh("mesh-C", 10); // 9/10 — most full
|
||||
for (let i = 0; i < 5; i++) t.recordJoin("mesh-B", `pB${i}`);
|
||||
for (let i = 0; i < 9; i++) t.recordJoin("mesh-C", `pC${i}`);
|
||||
const b = new MeshBalancer({ tracker: t });
|
||||
expect(b.computeImbalance()).toBeCloseTo(0.9, 5);
|
||||
});
|
||||
|
||||
it("returns empty plan when imbalance is below threshold", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 10);
|
||||
t.recordMesh("mesh-B", 10);
|
||||
t.recordJoin("mesh-A", "p1");
|
||||
const b = new MeshBalancer({
|
||||
tracker: t,
|
||||
imbalanceThreshold: 0.5,
|
||||
});
|
||||
const plan = b.buildPlan(() => []);
|
||||
expect(plan.moves).toEqual([]);
|
||||
expect(plan.imbalanceBefore).toBe(plan.imbalanceAfter);
|
||||
});
|
||||
|
||||
it("proposes moves when imbalance exceeds threshold", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 10);
|
||||
t.recordMesh("mesh-B", 10);
|
||||
for (let i = 0; i < 8; i++) t.recordJoin("mesh-A", `p${i}`);
|
||||
const b = new MeshBalancer({
|
||||
tracker: t,
|
||||
imbalanceThreshold: 0.3,
|
||||
maxMovesPerPlan: 3,
|
||||
});
|
||||
const plan = b.buildPlan((m) => {
|
||||
expect(m).toBe("mesh-A");
|
||||
return ["p0", "p1", "p2", "p3", "p4"];
|
||||
});
|
||||
expect(plan.moves).toHaveLength(3);
|
||||
for (const move of plan.moves) {
|
||||
expect(move.fromMeshId).toBe("mesh-A");
|
||||
expect(move.toMeshId).toBe("mesh-B");
|
||||
expect(move.reason).toBe("rebalance");
|
||||
}
|
||||
expect(plan.imbalanceAfter).toBeLessThan(plan.imbalanceBefore);
|
||||
});
|
||||
|
||||
it("uses default thresholds when not configured", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
const b = new MeshBalancer({ tracker: t });
|
||||
expect(b.computeImbalance()).toBe(0);
|
||||
});
|
||||
|
||||
it("estimateAfter returns 0 when one mesh reports zero max", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 10);
|
||||
t.recordMesh("mesh-B", 1);
|
||||
for (let i = 0; i < 8; i++) t.recordJoin("mesh-A", `p${i}`);
|
||||
const fakeTracker = Object.create(t) as MeshCapacityTracker;
|
||||
const originalGetMax = t.getMaxPeers.bind(t);
|
||||
fakeTracker.getMaxPeers = (mesh: string) =>
|
||||
mesh === "mesh-B" ? 0 : originalGetMax(mesh);
|
||||
const balancer = new MeshBalancer({
|
||||
tracker: fakeTracker,
|
||||
imbalanceThreshold: 0.3,
|
||||
maxMovesPerPlan: 1,
|
||||
});
|
||||
const plan = balancer.buildPlan(() => ["p0"]);
|
||||
expect(plan.imbalanceAfter).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 imbalance when both meshes have 0 max", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
const b = new MeshBalancer({ tracker: t });
|
||||
expect(b.computeImbalance()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
# MeshBalancer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Detects mesh imbalances and proposes rebalance moves. The plan is advisory — the consumer is responsible for actually executing each move via `network-plugin-migration`. Imbalance is measured as the spread between the highest- and lowest-utilised meshes; below `imbalanceThreshold` the plan is empty.
|
||||
|
||||
## Features
|
||||
|
||||
- `computeImbalance()` returns the current `[0, 1]` spread between the busiest and least-busy meshes.
|
||||
- `buildPlan(peerSelector)` returns a structured `RebalancePlan` with capped move count.
|
||||
- Configurable `imbalanceThreshold` (default `0.3`) and `maxMovesPerPlan` (default `10`).
|
||||
- Pluggable `peerSelector` — the consumer decides which peers can move from the overloaded mesh.
|
||||
- Plan includes both pre and post-move imbalance estimates for observability.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MeshBalancer,
|
||||
MeshCapacityTracker,
|
||||
} from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
const tracker = new MeshCapacityTracker();
|
||||
const balancer = new MeshBalancer({ tracker });
|
||||
|
||||
const plan = balancer.buildPlan((overloadedMeshId) => [
|
||||
"alice",
|
||||
"bob",
|
||||
]);
|
||||
|
||||
for (const move of plan.moves) {
|
||||
await migrationCoordinator.initiateMigration({
|
||||
peerId: move.peerId,
|
||||
fromMeshId: move.fromMeshId,
|
||||
toMeshId: move.toMeshId,
|
||||
reason: move.reason,
|
||||
priority: "graceful",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MeshBalancer,
|
||||
MeshCapacityTracker,
|
||||
} from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
const balancer = new MeshBalancer({
|
||||
tracker,
|
||||
imbalanceThreshold: 0.5,
|
||||
maxMovesPerPlan: 3,
|
||||
});
|
||||
|
||||
if (balancer.computeImbalance() >= 0.4) {
|
||||
const plan = balancer.buildPlan((meshId) =>
|
||||
registry.peersInMesh(meshId).map((p) => p.peerId)
|
||||
);
|
||||
metrics.gauge("mesh.imbalance.before", plan.imbalanceBefore);
|
||||
metrics.gauge("mesh.imbalance.after", plan.imbalanceAfter);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RebalanceMove { peerId, fromMeshId, toMeshId, reason }`.
|
||||
- `interface RebalancePlan { moves, imbalanceBefore, imbalanceAfter }`.
|
||||
- `interface MeshBalancerConfig { tracker, imbalanceThreshold?, maxMovesPerPlan? }`.
|
||||
- `computeImbalance(): number`.
|
||||
- `buildPlan(peerSelector): RebalancePlan`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The balancer picks the single most-overloaded mesh and the single least-loaded mesh per plan rather than computing an N-way redistribution; this keeps each plan small enough to execute without congesting the migration plugin and lets repeated plan iterations converge on equilibrium across cycles. The peer selector is a closure rather than a tracked attribute because peer eligibility depends on mesh-membership data we deliberately do not duplicate inside the balancer — that lives in `network-plugin-registry`.
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { MeshCapacityTracker } from "../tracker/mesh-capacity-tracker.js";
|
||||
|
||||
/**
|
||||
* One rebalance move proposal.
|
||||
*/
|
||||
export interface RebalanceMove {
|
||||
readonly peerId: string;
|
||||
readonly fromMeshId: string;
|
||||
readonly toMeshId: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebalance plan returned by `MeshBalancer`.
|
||||
*/
|
||||
export interface RebalancePlan {
|
||||
readonly moves: readonly RebalanceMove[];
|
||||
readonly imbalanceBefore: number;
|
||||
readonly imbalanceAfter: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `MeshBalancer`.
|
||||
*/
|
||||
export interface MeshBalancerConfig {
|
||||
readonly tracker: MeshCapacityTracker;
|
||||
readonly imbalanceThreshold?: number;
|
||||
readonly maxMovesPerPlan?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects mesh imbalances and proposes rebalance moves. The plan is
|
||||
* advisory — the consumer is responsible for actually executing each
|
||||
* move via `network-plugin-migration`. Imbalance is measured as the
|
||||
* spread between the highest- and lowest-utilised meshes.
|
||||
*/
|
||||
export class MeshBalancer {
|
||||
private readonly tracker: MeshCapacityTracker;
|
||||
private readonly imbalanceThreshold: number;
|
||||
private readonly maxMovesPerPlan: number;
|
||||
|
||||
public constructor(config: MeshBalancerConfig) {
|
||||
this.tracker = config.tracker;
|
||||
this.imbalanceThreshold = config.imbalanceThreshold ?? 0.3;
|
||||
this.maxMovesPerPlan = config.maxMovesPerPlan ?? 10;
|
||||
}
|
||||
|
||||
public computeImbalance(): number {
|
||||
const meshes = this.tracker.listMeshes();
|
||||
if (meshes.length < 2) return 0;
|
||||
let min = 1;
|
||||
let max = 0;
|
||||
for (const m of meshes) {
|
||||
const u = this.tracker.getUtilization(m);
|
||||
if (u < min) min = u;
|
||||
if (u > max) max = u;
|
||||
}
|
||||
return Math.max(0, max - min);
|
||||
}
|
||||
|
||||
public buildPlan(
|
||||
peerSelector: (overloadedMeshId: string) => readonly string[]
|
||||
): RebalancePlan {
|
||||
const before = this.computeImbalance();
|
||||
if (before < this.imbalanceThreshold) {
|
||||
return {
|
||||
moves: [],
|
||||
imbalanceBefore: before,
|
||||
imbalanceAfter: before,
|
||||
};
|
||||
}
|
||||
const utilisation = this.tracker
|
||||
.listMeshes()
|
||||
.map((meshId) => ({
|
||||
meshId,
|
||||
util: this.tracker.getUtilization(meshId),
|
||||
}))
|
||||
.sort((a, b) => b.util - a.util);
|
||||
const overloaded = utilisation[0]!;
|
||||
const underloaded = utilisation[utilisation.length - 1]!;
|
||||
const moves: RebalanceMove[] = [];
|
||||
const peersToMove = peerSelector(overloaded.meshId).slice(
|
||||
0,
|
||||
this.maxMovesPerPlan
|
||||
);
|
||||
for (const peerId of peersToMove) {
|
||||
moves.push({
|
||||
peerId,
|
||||
fromMeshId: overloaded.meshId,
|
||||
toMeshId: underloaded.meshId,
|
||||
reason: "rebalance",
|
||||
});
|
||||
}
|
||||
return {
|
||||
moves,
|
||||
imbalanceBefore: before,
|
||||
imbalanceAfter: this.estimateAfter(
|
||||
overloaded.meshId,
|
||||
underloaded.meshId,
|
||||
moves.length
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private estimateAfter(
|
||||
fromMeshId: string,
|
||||
toMeshId: string,
|
||||
moveCount: number
|
||||
): number {
|
||||
const fromMax = this.tracker.getMaxPeers(fromMeshId);
|
||||
const toMax = this.tracker.getMaxPeers(toMeshId);
|
||||
if (fromMax === 0 || toMax === 0) return 0;
|
||||
const fromAfter =
|
||||
(this.tracker.getUtilization(fromMeshId) * fromMax - moveCount) /
|
||||
fromMax;
|
||||
const toAfter =
|
||||
(this.tracker.getUtilization(toMeshId) * toMax + moveCount) / toMax;
|
||||
return Math.abs(toAfter - fromAfter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# AdmissionController
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Gatekeeper that runs the `AssignmentEngine`, checks capacity against the `MeshCapacityTracker`, applies a pluggable `AdmissionPolicy`, and either records the placement or throws a typed error. Centralising admission here means there's one entry point for "place this peer somewhere" and the engine, balancer, and observability all see the same outcome.
|
||||
|
||||
## Features
|
||||
|
||||
- Pluggable `AdmissionPolicy` for non-capacity gates (capability mismatch, regional rules, operator quotas).
|
||||
- Mesh enumeration via injected `meshes()` callback so the controller never has to keep a stale local copy.
|
||||
- Three failure modes with distinct codes: `ADMISSION_DENIED` (policy), `NO_ELIGIBLE_MESH` (engine), `MESH_FULL` (tracker race).
|
||||
- `release(peer, meshId)` for clean disconnect semantics.
|
||||
- On success, records the join into the tracker so the next call sees the updated capacity.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AdmissionController,
|
||||
AssignmentEngine,
|
||||
MeshCapacityTracker,
|
||||
} from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
const tracker = new MeshCapacityTracker();
|
||||
const engine = new AssignmentEngine({ tracker });
|
||||
const controller = new AdmissionController({
|
||||
engine,
|
||||
tracker,
|
||||
meshes: () => [
|
||||
{ meshId: "mesh-A", tierFloor: 0 },
|
||||
{ meshId: "mesh-B", tierFloor: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = controller.admit(peer);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ASSIGN_CODES,
|
||||
AdmissionController,
|
||||
AssignError,
|
||||
} from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
const controller = new AdmissionController({
|
||||
engine,
|
||||
tracker,
|
||||
meshes: () => fleetSnapshot,
|
||||
policy: (peer, mesh) => {
|
||||
if (peer.capabilities === "CONSTRAINED" && mesh.tierFloor < 2) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const placement = controller.admit(peer);
|
||||
bus.emit("peer:placed", placement);
|
||||
} catch (err) {
|
||||
if (err instanceof AssignError) {
|
||||
if (err.code === ASSIGN_CODES.MESH_FULL) {
|
||||
await rebalanceAndRetry(peer);
|
||||
return;
|
||||
}
|
||||
if (err.code === ASSIGN_CODES.ADMISSION_DENIED) {
|
||||
return enterStandby(peer);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type AdmissionPolicy = (peer, descriptor) => boolean`.
|
||||
- `interface AdmissionControllerConfig { engine, tracker, meshes, policy? }`.
|
||||
- `interface AdmissionResult { peerId, meshId, score }`.
|
||||
- `admit(peer): AdmissionResult` — throws `ADMISSION_DENIED` / `NO_ELIGIBLE_MESH` / `MESH_FULL`.
|
||||
- `release(peer, meshId)` — updates the tracker.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`admit` does the policy check _before_ calling the engine so that policy violations are detected before we burn cycles scoring meshes the peer can't join anyway. The capacity check happens _after_ the engine picks a winner, because another concurrent admission could race the available-slots count down to zero between when the engine scored and when we record. Callers that need stricter ordering should serialize admissions through their own queue.
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
AssignmentEngine,
|
||||
MeshDescriptor,
|
||||
PeerInfo,
|
||||
} from "../engine/assignment-engine.js";
|
||||
import { ASSIGN_CODES, AssignError } from "../error-codes.js";
|
||||
import type { MeshCapacityTracker } from "../tracker/mesh-capacity-tracker.js";
|
||||
|
||||
/**
|
||||
* Function that vets a peer for non-capacity reasons (capability
|
||||
* mismatch, regional policy, etc.).
|
||||
*/
|
||||
export type AdmissionPolicy = (
|
||||
peer: PeerInfo,
|
||||
descriptor: MeshDescriptor
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Configuration for `AdmissionController`.
|
||||
*/
|
||||
export interface AdmissionControllerConfig {
|
||||
readonly engine: AssignmentEngine;
|
||||
readonly tracker: MeshCapacityTracker;
|
||||
readonly meshes: () => readonly MeshDescriptor[];
|
||||
readonly policy?: AdmissionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a successful admission.
|
||||
*/
|
||||
export interface AdmissionResult {
|
||||
readonly peerId: string;
|
||||
readonly meshId: string;
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gatekeeper that runs the engine, checks capacity, applies policy, and
|
||||
* either records the placement (via the tracker) or throws a typed
|
||||
* error.
|
||||
*/
|
||||
export class AdmissionController {
|
||||
private readonly config: AdmissionControllerConfig;
|
||||
private readonly policy: AdmissionPolicy;
|
||||
|
||||
public constructor(config: AdmissionControllerConfig) {
|
||||
this.config = config;
|
||||
this.policy = config.policy ?? (() => true);
|
||||
}
|
||||
|
||||
public admit(peer: PeerInfo): AdmissionResult {
|
||||
const descriptors = this.config.meshes();
|
||||
const eligible = descriptors.filter((d) => this.policy(peer, d));
|
||||
if (eligible.length === 0) {
|
||||
throw new AssignError(
|
||||
ASSIGN_CODES.ADMISSION_DENIED,
|
||||
"admission.denied-by-policy",
|
||||
{ context: { peerId: peer.peerId } }
|
||||
);
|
||||
}
|
||||
const best = this.config.engine.bestFor(peer, eligible);
|
||||
if (this.config.tracker.getAvailableSlots(best.meshId) <= 0) {
|
||||
throw new AssignError(
|
||||
ASSIGN_CODES.MESH_FULL,
|
||||
"admission.mesh-full",
|
||||
{
|
||||
context: {
|
||||
peerId: peer.peerId,
|
||||
meshId: best.meshId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
this.config.tracker.recordJoin(best.meshId, peer.peerId);
|
||||
return {
|
||||
peerId: peer.peerId,
|
||||
meshId: best.meshId,
|
||||
score: best.score,
|
||||
};
|
||||
}
|
||||
|
||||
public release(peer: PeerInfo, meshId: string): void {
|
||||
this.config.tracker.recordLeave(meshId, peer.peerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AssignmentEngine,
|
||||
type MeshDescriptor,
|
||||
type PeerInfo,
|
||||
} from "../engine/assignment-engine.js";
|
||||
import { MeshCapacityTracker } from "../tracker/mesh-capacity-tracker.js";
|
||||
import { AdmissionController } from "./admission-controller.js";
|
||||
|
||||
const peer: PeerInfo = {
|
||||
peerId: "alice",
|
||||
latencyMap: new Map([
|
||||
["mesh-A", 50],
|
||||
["mesh-B", 200],
|
||||
]),
|
||||
region: "us-east",
|
||||
tierEligibility: 1,
|
||||
capabilities: "STANDARD",
|
||||
};
|
||||
|
||||
function setup(
|
||||
opts: {
|
||||
meshes?: MeshDescriptor[];
|
||||
capA?: number;
|
||||
fillA?: number;
|
||||
policy?: (p: PeerInfo, m: MeshDescriptor) => boolean;
|
||||
} = {}
|
||||
) {
|
||||
const tracker = new MeshCapacityTracker();
|
||||
tracker.recordMesh("mesh-A", opts.capA ?? 10);
|
||||
tracker.recordMesh("mesh-B", 10);
|
||||
for (let i = 0; i < (opts.fillA ?? 0); i++) {
|
||||
tracker.recordJoin("mesh-A", `p${i}`);
|
||||
}
|
||||
const engine = new AssignmentEngine({ tracker });
|
||||
const meshes =
|
||||
opts.meshes ??
|
||||
([
|
||||
{ meshId: "mesh-A", region: "us-east", tierFloor: 0 },
|
||||
{ meshId: "mesh-B", region: "us-west", tierFloor: 0 },
|
||||
] as MeshDescriptor[]);
|
||||
const controller = new AdmissionController({
|
||||
engine,
|
||||
tracker,
|
||||
meshes: () => meshes,
|
||||
policy: opts.policy,
|
||||
});
|
||||
return { tracker, controller };
|
||||
}
|
||||
|
||||
describe("AdmissionController", () => {
|
||||
it("admits a peer to the best mesh and records the join", () => {
|
||||
const { tracker, controller } = setup();
|
||||
const result = controller.admit(peer);
|
||||
expect(result.meshId).toBe("mesh-A");
|
||||
expect(tracker.getUtilization("mesh-A")).toBe(0.1);
|
||||
});
|
||||
|
||||
it("rejects when policy denies every mesh", () => {
|
||||
const { controller } = setup({ policy: () => false });
|
||||
expect(() => controller.admit(peer)).toThrow(
|
||||
"admission.denied-by-policy"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when chosen mesh is at capacity", () => {
|
||||
// restrict policy to mesh-A so the engine can't pick mesh-B,
|
||||
// then fill mesh-A so admit hits MESH_FULL deterministically.
|
||||
const { controller } = setup({
|
||||
capA: 1,
|
||||
fillA: 1,
|
||||
policy: (_p, m) => m.meshId === "mesh-A",
|
||||
});
|
||||
expect(() => controller.admit(peer)).toThrow("admission.mesh-full");
|
||||
});
|
||||
|
||||
it("propagates NO_ELIGIBLE_MESH from the engine", () => {
|
||||
const { controller } = setup({
|
||||
meshes: [
|
||||
{ meshId: "mesh-A", tierFloor: 99 },
|
||||
{ meshId: "mesh-B", tierFloor: 99 },
|
||||
],
|
||||
});
|
||||
expect(() => controller.admit(peer)).toThrow(
|
||||
"assignment.no-eligible-mesh"
|
||||
);
|
||||
});
|
||||
|
||||
it("release decrements the tracker", () => {
|
||||
const { tracker, controller } = setup();
|
||||
const result = controller.admit(peer);
|
||||
controller.release(peer, result.meshId);
|
||||
expect(tracker.getUtilization(result.meshId)).toBe(0);
|
||||
});
|
||||
|
||||
it("default policy admits all", () => {
|
||||
const { controller } = setup();
|
||||
expect(() => controller.admit(peer)).not.toThrow();
|
||||
});
|
||||
|
||||
it("policy can filter per-mesh and admission picks survivors", () => {
|
||||
const { controller } = setup({
|
||||
policy: (_p, m) => m.meshId === "mesh-B",
|
||||
});
|
||||
const result = controller.admit(peer);
|
||||
expect(result.meshId).toBe("mesh-B");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
# AssignmentEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Scores candidate meshes for a placement decision along five axes (latency, capacity, load, region, tier-fit), combines them into a composite score, and returns the best mesh that still has room. Decoupling the scoring from the admission and balancer means consumers can run "what-if" scoring without committing to a placement, and the rebalancer can use the same scoring to evaluate moves.
|
||||
|
||||
## Features
|
||||
|
||||
- Five scoring axes, each normalised to `[0, 1]`.
|
||||
- Configurable `AssignmentWeights` (defaults `0.35 / 0.25 / 0.2 / 0.1 / 0.1`).
|
||||
- Configurable `minScoreThreshold` for "this mesh is good enough" decisions.
|
||||
- Tier-fit pre-filter: peers below `tierFloor` are excluded from scoring entirely.
|
||||
- Deterministic lex-tiebreak on `meshId` so independent observers reach the same winner.
|
||||
- Throws `NO_ELIGIBLE_MESH` for empty candidate sets and below-threshold scores.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AssignmentEngine,
|
||||
MeshCapacityTracker,
|
||||
} from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
const tracker = new MeshCapacityTracker();
|
||||
tracker.recordMesh("mesh-A", 32);
|
||||
tracker.recordMesh("mesh-B", 32);
|
||||
|
||||
const engine = new AssignmentEngine({ tracker });
|
||||
const best = engine.bestFor(
|
||||
{
|
||||
peerId: "alice",
|
||||
latencyMap: new Map([
|
||||
["mesh-A", 50],
|
||||
["mesh-B", 200],
|
||||
]),
|
||||
tierEligibility: 1,
|
||||
capabilities: "STANDARD",
|
||||
},
|
||||
[
|
||||
{ meshId: "mesh-A", tierFloor: 0 },
|
||||
{ meshId: "mesh-B", tierFloor: 0 },
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AssignmentEngine,
|
||||
MeshCapacityTracker,
|
||||
} from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
const engine = new AssignmentEngine({
|
||||
tracker,
|
||||
weights: {
|
||||
latency: 0.5,
|
||||
capacity: 0.3,
|
||||
load: 0.1,
|
||||
region: 0.1,
|
||||
tierFit: 0,
|
||||
},
|
||||
minScoreThreshold: 0.4,
|
||||
});
|
||||
|
||||
const scored = engine.score(peer, candidates);
|
||||
for (const s of scored) {
|
||||
console.log(s.meshId, s.score, s.factors);
|
||||
}
|
||||
|
||||
try {
|
||||
const best = engine.bestFor(peer, candidates);
|
||||
} catch (err) {
|
||||
// handle NO_ELIGIBLE_MESH
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface PeerInfo { peerId, latencyMap, region?, tierEligibility, capabilities }`.
|
||||
- `interface MeshDescriptor { meshId, region?, tierFloor }`.
|
||||
- `interface AssignmentWeights { latency, capacity, load, region, tierFit }`.
|
||||
- `interface AssignmentScore { meshId, score, factors }`.
|
||||
- `interface AssignmentEngineConfig { tracker, weights?, minScoreThreshold? }`.
|
||||
- `score(peer, candidates): readonly AssignmentScore[]`.
|
||||
- `bestFor(peer, candidates): AssignmentScore` — throws `NO_ELIGIBLE_MESH` on empty / below-threshold.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The latency axis uses `1 / (1 + ms)` rather than min-max normalisation so a peer that has only one latency reading still produces a sensible score (no degenerate divide-by-zero). Capacity and load are deliberately separate axes — capacity is "how many free slots" while load is "how full"; they correlate but a tiny mesh and a huge mesh at the same utilisation should rank differently for placement, which the dual-axis lets us tune. Tier-fit is enforced as a _filter_ before scoring, then re-asserted as a binary axis, so we never score a peer against a mesh it cannot reach.
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ASSIGN_CODES, AssignError } from "../error-codes.js";
|
||||
import type { MeshCapacityTracker } from "../tracker/mesh-capacity-tracker.js";
|
||||
|
||||
/**
|
||||
* Information about a peer requesting placement.
|
||||
*/
|
||||
export interface PeerInfo {
|
||||
readonly peerId: string;
|
||||
readonly latencyMap: ReadonlyMap<string, number>;
|
||||
readonly region?: string;
|
||||
readonly tierEligibility: number;
|
||||
readonly capabilities: "CONSTRAINED" | "STANDARD" | "POWERFUL" | "SERVER";
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-mesh metadata supplied to the engine.
|
||||
*/
|
||||
export interface MeshDescriptor {
|
||||
readonly meshId: string;
|
||||
readonly region?: string;
|
||||
readonly tierFloor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight of each scoring axis. Must be non-negative.
|
||||
*/
|
||||
export interface AssignmentWeights {
|
||||
readonly latency: number;
|
||||
readonly capacity: number;
|
||||
readonly load: number;
|
||||
readonly region: number;
|
||||
readonly tierFit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score breakdown for one candidate mesh.
|
||||
*/
|
||||
export interface AssignmentScore {
|
||||
readonly meshId: string;
|
||||
readonly score: number;
|
||||
readonly factors: {
|
||||
readonly latency: number;
|
||||
readonly capacity: number;
|
||||
readonly load: number;
|
||||
readonly region: number;
|
||||
readonly tierFit: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `AssignmentEngine`.
|
||||
*/
|
||||
export interface AssignmentEngineConfig {
|
||||
readonly tracker: MeshCapacityTracker;
|
||||
readonly weights?: AssignmentWeights;
|
||||
readonly minScoreThreshold?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_WEIGHTS: AssignmentWeights = {
|
||||
latency: 0.35,
|
||||
capacity: 0.25,
|
||||
load: 0.2,
|
||||
region: 0.1,
|
||||
tierFit: 0.1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Scores candidate meshes for a peer along five axes:
|
||||
*
|
||||
* - `latency` — lower RTT wins (`1 / (1 + ms)`).
|
||||
* - `capacity` — fraction of free slots.
|
||||
* - `load` — `1 - utilization`.
|
||||
* - `region` — `1` if regions match, else `0`.
|
||||
* - `tierFit` — `1` if peer is eligible for the mesh's tier floor, else `0`.
|
||||
*
|
||||
* Returns the highest-scoring mesh that still has free capacity, or
|
||||
* throws `NO_ELIGIBLE_MESH` if every candidate scores below the
|
||||
* threshold.
|
||||
*/
|
||||
export class AssignmentEngine {
|
||||
private readonly tracker: MeshCapacityTracker;
|
||||
private readonly weights: AssignmentWeights;
|
||||
private readonly minScoreThreshold: number;
|
||||
|
||||
public constructor(config: AssignmentEngineConfig) {
|
||||
this.tracker = config.tracker;
|
||||
this.weights = config.weights ?? DEFAULT_WEIGHTS;
|
||||
this.minScoreThreshold = config.minScoreThreshold ?? 0;
|
||||
}
|
||||
|
||||
public score(
|
||||
peer: PeerInfo,
|
||||
candidates: readonly MeshDescriptor[]
|
||||
): readonly AssignmentScore[] {
|
||||
return candidates
|
||||
.filter((m) => peer.tierEligibility >= m.tierFloor)
|
||||
.map((m) => {
|
||||
const latencyMs = peer.latencyMap.get(m.meshId);
|
||||
const latency =
|
||||
latencyMs === undefined ? 0 : 1 / (1 + latencyMs);
|
||||
const slots = this.tracker.getAvailableSlots(m.meshId);
|
||||
const max = this.tracker.getMaxPeers(m.meshId) || 1;
|
||||
const capacity = slots / max;
|
||||
const load = 1 - this.tracker.getUtilization(m.meshId);
|
||||
const region =
|
||||
m.region !== undefined &&
|
||||
peer.region !== undefined &&
|
||||
m.region === peer.region
|
||||
? 1
|
||||
: 0;
|
||||
// tierEligibility is always >= tierFloor here because the
|
||||
// outer .filter() pre-screens ineligible candidates.
|
||||
const tierFit = 1;
|
||||
const composite =
|
||||
this.weights.latency * latency +
|
||||
this.weights.capacity * capacity +
|
||||
this.weights.load * load +
|
||||
this.weights.region * region +
|
||||
this.weights.tierFit * tierFit;
|
||||
return {
|
||||
meshId: m.meshId,
|
||||
score: composite,
|
||||
factors: { latency, capacity, load, region, tierFit },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public bestFor(
|
||||
peer: PeerInfo,
|
||||
candidates: readonly MeshDescriptor[]
|
||||
): AssignmentScore {
|
||||
const scored = this.score(peer, candidates);
|
||||
if (scored.length === 0) {
|
||||
throw new AssignError(
|
||||
ASSIGN_CODES.NO_ELIGIBLE_MESH,
|
||||
"assignment.no-eligible-mesh",
|
||||
{ context: { peerId: peer.peerId } }
|
||||
);
|
||||
}
|
||||
const sorted = [...scored].sort((a, b) => {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
return a.meshId.localeCompare(b.meshId);
|
||||
});
|
||||
const best = sorted[0]!;
|
||||
if (best.score < this.minScoreThreshold) {
|
||||
throw new AssignError(
|
||||
ASSIGN_CODES.NO_ELIGIBLE_MESH,
|
||||
"assignment.below-threshold",
|
||||
{
|
||||
context: {
|
||||
peerId: peer.peerId,
|
||||
bestScore: best.score,
|
||||
threshold: this.minScoreThreshold,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MeshCapacityTracker } from "../tracker/mesh-capacity-tracker.js";
|
||||
import {
|
||||
AssignmentEngine,
|
||||
type MeshDescriptor,
|
||||
type PeerInfo,
|
||||
} from "./assignment-engine.js";
|
||||
|
||||
function setupTracker(): MeshCapacityTracker {
|
||||
const tracker = new MeshCapacityTracker();
|
||||
tracker.recordMesh("mesh-A", 10);
|
||||
tracker.recordMesh("mesh-B", 10);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
const candidates: MeshDescriptor[] = [
|
||||
{ meshId: "mesh-A", region: "us-east", tierFloor: 0 },
|
||||
{ meshId: "mesh-B", region: "us-west", tierFloor: 0 },
|
||||
];
|
||||
|
||||
const peer: PeerInfo = {
|
||||
peerId: "alice",
|
||||
latencyMap: new Map([
|
||||
["mesh-A", 50],
|
||||
["mesh-B", 200],
|
||||
]),
|
||||
region: "us-east",
|
||||
tierEligibility: 1,
|
||||
capabilities: "STANDARD",
|
||||
};
|
||||
|
||||
describe("AssignmentEngine", () => {
|
||||
it("scores meshes and ranks the closest one first", () => {
|
||||
const engine = new AssignmentEngine({ tracker: setupTracker() });
|
||||
const best = engine.bestFor(peer, candidates);
|
||||
expect(best.meshId).toBe("mesh-A");
|
||||
expect(best.factors.region).toBe(1);
|
||||
});
|
||||
|
||||
it("filters out ineligible meshes by tier floor", () => {
|
||||
const engine = new AssignmentEngine({ tracker: setupTracker() });
|
||||
const tier0Only: MeshDescriptor[] = [
|
||||
{ meshId: "mesh-A", tierFloor: 5 }, // peer can't reach
|
||||
{ meshId: "mesh-B", tierFloor: 0 },
|
||||
];
|
||||
const best = engine.bestFor(peer, tier0Only);
|
||||
expect(best.meshId).toBe("mesh-B");
|
||||
});
|
||||
|
||||
it("throws NO_ELIGIBLE_MESH when no candidates remain", () => {
|
||||
const engine = new AssignmentEngine({ tracker: setupTracker() });
|
||||
expect(() =>
|
||||
engine.bestFor(peer, [{ meshId: "mesh-X", tierFloor: 99 }])
|
||||
).toThrow("assignment.no-eligible-mesh");
|
||||
});
|
||||
|
||||
it("throws NO_ELIGIBLE_MESH below threshold", () => {
|
||||
const engine = new AssignmentEngine({
|
||||
tracker: setupTracker(),
|
||||
minScoreThreshold: 0.99,
|
||||
});
|
||||
expect(() => engine.bestFor(peer, candidates)).toThrow(
|
||||
"assignment.below-threshold"
|
||||
);
|
||||
});
|
||||
|
||||
it("treats missing latency as zero score on that axis", () => {
|
||||
const engine = new AssignmentEngine({ tracker: setupTracker() });
|
||||
const noLatencyPeer: PeerInfo = {
|
||||
...peer,
|
||||
latencyMap: new Map(),
|
||||
};
|
||||
const scored = engine.score(noLatencyPeer, candidates);
|
||||
for (const s of scored) {
|
||||
expect(s.factors.latency).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("region axis is 0 when peer has no region", () => {
|
||||
const engine = new AssignmentEngine({ tracker: setupTracker() });
|
||||
const noRegionPeer: PeerInfo = { ...peer, region: undefined };
|
||||
const scored = engine.score(noRegionPeer, candidates);
|
||||
for (const s of scored) {
|
||||
expect(s.factors.region).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("region axis is 0 when descriptor has no region", () => {
|
||||
const engine = new AssignmentEngine({ tracker: setupTracker() });
|
||||
const noRegionMeshes: MeshDescriptor[] = [
|
||||
{ meshId: "mesh-A", tierFloor: 0 },
|
||||
{ meshId: "mesh-B", tierFloor: 0 },
|
||||
];
|
||||
const scored = engine.score(peer, noRegionMeshes);
|
||||
for (const s of scored) {
|
||||
expect(s.factors.region).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects custom weights", () => {
|
||||
const tracker = setupTracker();
|
||||
for (let i = 0; i < 9; i++) tracker.recordJoin("mesh-A", `p${i}`);
|
||||
const customEngine = new AssignmentEngine({
|
||||
tracker,
|
||||
weights: {
|
||||
latency: 0,
|
||||
capacity: 1,
|
||||
load: 0,
|
||||
region: 0,
|
||||
tierFit: 0,
|
||||
},
|
||||
});
|
||||
const best = customEngine.bestFor(peer, candidates);
|
||||
expect(best.meshId).toBe("mesh-B");
|
||||
});
|
||||
|
||||
it("lex-tiebreaks on meshId for identical scores", () => {
|
||||
const tracker = setupTracker();
|
||||
const engine = new AssignmentEngine({
|
||||
tracker,
|
||||
weights: {
|
||||
latency: 0,
|
||||
capacity: 0,
|
||||
load: 0,
|
||||
region: 0,
|
||||
tierFit: 1,
|
||||
},
|
||||
});
|
||||
const best = engine.bestFor(peer, candidates);
|
||||
expect(best.meshId).toBe("mesh-A");
|
||||
});
|
||||
|
||||
it("handles a tracker that reports zero maxPeers", () => {
|
||||
const tracker = new MeshCapacityTracker();
|
||||
const engine = new AssignmentEngine({ tracker });
|
||||
const scored = engine.score(peer, candidates);
|
||||
for (const s of scored) {
|
||||
expect(s.factors.capacity).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# ASSIGN_CODES
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_MESH` when the engine cannot score any mesh above the threshold, `MESH_FULL` when the chosen mesh's capacity is exhausted at the moment of admission, and `ADMISSION_DENIED` when the admission controller refuses for a non-capacity reason (capability mismatch, regional policy, etc.). The companion `AssignError` extends `SuaveplanError<AssignErrorCode>` so consumers pattern-match on `code` rather than parse messages.
|
||||
|
||||
## Features
|
||||
|
||||
- Three frozen codes registered under the `NET_ASSIGN` namespace.
|
||||
- `AssignError extends SuaveplanError<AssignErrorCode>`.
|
||||
- `name: "AssignError"` 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.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ASSIGN_CODES,
|
||||
AssignError,
|
||||
} from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
throw new AssignError(
|
||||
ASSIGN_CODES.NO_ELIGIBLE_MESH,
|
||||
"assignment.no-eligible-mesh",
|
||||
{ context: { peerId: "alice", knownMeshes: 4 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ASSIGN_CODES,
|
||||
AssignError,
|
||||
} from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
try {
|
||||
const meshId = await admissionController.admit(peerInfo);
|
||||
} catch (err) {
|
||||
if (err instanceof AssignError) {
|
||||
if (err.code === ASSIGN_CODES.MESH_FULL) {
|
||||
await rebalanceAndRetry(peerInfo);
|
||||
return;
|
||||
}
|
||||
if (err.code === ASSIGN_CODES.NO_ELIGIBLE_MESH) {
|
||||
return enterStandbyMode(peerInfo);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ASSIGN_CODES` — frozen `{ NO_ELIGIBLE_MESH, MESH_FULL, ADMISSION_DENIED }`.
|
||||
- `type AssignErrorCode` — union of registered code values.
|
||||
- `class AssignError extends SuaveplanError<AssignErrorCode>` — typed throwable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`MESH_FULL` is intentionally distinct from `NO_ELIGIBLE_MESH` because they imply different recovery profiles: the former says "the engine picked a mesh but admission lost the race — try again", while the latter says "no mesh in the fleet can host this peer at all — escalate". `ADMISSION_DENIED` covers everything else (capability mismatch, regional policy violation, operator quota cap) so callers don't have to multiplex unrelated codes onto the same handler. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ASSIGN_CODES, AssignError } from "./error-codes.js";
|
||||
|
||||
describe("ASSIGN_CODES", () => {
|
||||
it("registers under NET_ASSIGN", () => {
|
||||
for (const value of Object.values(ASSIGN_CODES)) {
|
||||
expect(value).toMatch(/^NET_ASSIGN\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(ASSIGN_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
for (const key of [
|
||||
"NO_ELIGIBLE_MESH",
|
||||
"MESH_FULL",
|
||||
"ADMISSION_DENIED",
|
||||
] as const) {
|
||||
expect(ASSIGN_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AssignError", () => {
|
||||
it("extends SuaveplanError", () => {
|
||||
const err = new AssignError(
|
||||
ASSIGN_CODES.NO_ELIGIBLE_MESH,
|
||||
"no-eligible"
|
||||
);
|
||||
expect(err.name).toBe("AssignError");
|
||||
expect(err.code).toBe(ASSIGN_CODES.NO_ELIGIBLE_MESH);
|
||||
});
|
||||
|
||||
it("preserves cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new AssignError(ASSIGN_CODES.MESH_FULL, "with-cause", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const ASSIGN_CODES = defineCodes("NET_ASSIGN", [
|
||||
"NO_ELIGIBLE_MESH",
|
||||
"MESH_FULL",
|
||||
"ADMISSION_DENIED",
|
||||
] as const);
|
||||
|
||||
export type AssignErrorCode = (typeof ASSIGN_CODES)[keyof typeof ASSIGN_CODES];
|
||||
|
||||
export class AssignError extends SuaveplanError<AssignErrorCode> {
|
||||
public constructor(
|
||||
code: AssignErrorCode,
|
||||
messageKey: string,
|
||||
options?: import("@suaveplan/error").SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "AssignError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Error codes (side-effect registration)
|
||||
import "./error-codes.js";
|
||||
|
||||
// Balancer
|
||||
export {
|
||||
MeshBalancer,
|
||||
type MeshBalancerConfig,
|
||||
type RebalanceMove,
|
||||
type RebalancePlan,
|
||||
} from "./balancer/mesh-balancer.js";
|
||||
// Controller
|
||||
export {
|
||||
AdmissionController,
|
||||
type AdmissionControllerConfig,
|
||||
type AdmissionPolicy,
|
||||
type AdmissionResult,
|
||||
} from "./controller/admission-controller.js";
|
||||
// Engine
|
||||
export {
|
||||
AssignmentEngine,
|
||||
type AssignmentEngineConfig,
|
||||
type AssignmentScore,
|
||||
type AssignmentWeights,
|
||||
type MeshDescriptor,
|
||||
type PeerInfo,
|
||||
} from "./engine/assignment-engine.js";
|
||||
// Errors
|
||||
export {
|
||||
ASSIGN_CODES,
|
||||
AssignError,
|
||||
type AssignErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Tracker
|
||||
export {
|
||||
type CapacityListener,
|
||||
MeshCapacityTracker,
|
||||
type MeshCapacityTrackerConfig,
|
||||
} from "./tracker/mesh-capacity-tracker.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# MeshCapacityTracker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Tracks `currentPeers / maxPeers` per mesh in real time. Push-driven: the consumer wires `peer:joined`/`peer:left` from `network-plugin-registry` into `recordJoin`/`recordLeave` and the tracker fires `mesh:full` / `mesh:available` listeners on the threshold transitions. Centralising the capacity view here means the assignment engine, balancer, and admission controller all read the same numbers.
|
||||
|
||||
## Features
|
||||
|
||||
- `recordMesh(meshId, maxPeers?)` upserts mesh capacity (default `defaultMaxPeers`, configurable; falls back to 64).
|
||||
- `recordJoin(meshId, peerId)` and `recordLeave(meshId, peerId)` keep the live set per mesh.
|
||||
- `getUtilization(meshId)` returns the `[0, 1]` ratio.
|
||||
- `getAvailableSlots(meshId)` for hot-path scheduling.
|
||||
- Listener-driven `mesh:full` / `mesh:available` transitions; unsubscribe handle returned from `on()`.
|
||||
- `listMeshes()` and `getMaxPeers()` exposed for observability.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MeshCapacityTracker } from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
const tracker = new MeshCapacityTracker();
|
||||
tracker.recordMesh("mesh-A", 32);
|
||||
tracker.recordJoin("mesh-A", "alice");
|
||||
tracker.recordJoin("mesh-A", "bob");
|
||||
const u = tracker.getUtilization("mesh-A"); // 2 / 32 = 0.0625
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MeshCapacityTracker } from "@suaveplan/network-plugin-assignment";
|
||||
|
||||
const tracker = new MeshCapacityTracker({ defaultMaxPeers: 16 });
|
||||
const off = tracker.on((event, meshId) => {
|
||||
if (event === "mesh:full") {
|
||||
rebalancer.scheduleRebalance(meshId);
|
||||
}
|
||||
});
|
||||
|
||||
bus.on("peer:joined", (e) => tracker.recordJoin(e.meshId, e.peerId));
|
||||
bus.on("peer:left", (e) => tracker.recordLeave(e.meshId, e.peerId));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface MeshCapacityTrackerConfig { defaultMaxPeers? }`.
|
||||
- `recordMesh(meshId, maxPeers?)` / `recordJoin(meshId, peerId)` / `recordLeave(meshId, peerId)`.
|
||||
- `getUtilization(meshId): number`.
|
||||
- `getAvailableSlots(meshId): number`.
|
||||
- `listMeshes(): readonly string[]`.
|
||||
- `getMaxPeers(meshId): number`.
|
||||
- `on(listener) → () => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
State is kept in two maps — the per-mesh peer set and a parallel `fullState` boolean — so `recheckFull` can observe a transition by comparing the previous and current values atomically. We only fire listeners on edge transitions (`true → false` or `false → true`), not on every record, to avoid storming subscribers when traffic is steady. `recordLeave` against an unknown mesh is a no-op rather than an error because clean-shutdown ordering can lose `recordMesh` events.
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Configuration for `MeshCapacityTracker`.
|
||||
*/
|
||||
export interface MeshCapacityTrackerConfig {
|
||||
readonly defaultMaxPeers?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener fired when a mesh transitions from "available" to "full" or
|
||||
* back.
|
||||
*/
|
||||
export type CapacityListener = (
|
||||
event: "mesh:full" | "mesh:available",
|
||||
meshId: string
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Tracks `currentPeers / maxPeers` per mesh in real time. Backed by
|
||||
* push-style `recordJoin`/`recordLeave` calls — the consumer wires the
|
||||
* `peer:joined`/`peer:left` events from `network-plugin-registry` into
|
||||
* the tracker.
|
||||
*/
|
||||
export class MeshCapacityTracker {
|
||||
private readonly defaultMaxPeers: number;
|
||||
private readonly meshes = new Map<
|
||||
string,
|
||||
{ maxPeers: number; currentPeers: Set<string> }
|
||||
>();
|
||||
private readonly fullState = new Map<string, boolean>();
|
||||
private readonly listeners = new Set<CapacityListener>();
|
||||
|
||||
public constructor(config: MeshCapacityTrackerConfig = {}) {
|
||||
this.defaultMaxPeers = config.defaultMaxPeers ?? 64;
|
||||
}
|
||||
|
||||
public recordMesh(meshId: string, maxPeers?: number): void {
|
||||
const existing = this.meshes.get(meshId);
|
||||
const cap = maxPeers ?? this.defaultMaxPeers;
|
||||
if (existing === undefined) {
|
||||
this.meshes.set(meshId, {
|
||||
maxPeers: cap,
|
||||
currentPeers: new Set(),
|
||||
});
|
||||
this.fullState.set(meshId, false);
|
||||
} else {
|
||||
existing.maxPeers = cap;
|
||||
this.recheckFull(meshId);
|
||||
}
|
||||
}
|
||||
|
||||
public recordJoin(meshId: string, peerId: string): void {
|
||||
const m = this.upsert(meshId);
|
||||
m.currentPeers.add(peerId);
|
||||
this.recheckFull(meshId);
|
||||
}
|
||||
|
||||
public recordLeave(meshId: string, peerId: string): void {
|
||||
const m = this.meshes.get(meshId);
|
||||
if (m === undefined) return;
|
||||
m.currentPeers.delete(peerId);
|
||||
this.recheckFull(meshId);
|
||||
}
|
||||
|
||||
public getUtilization(meshId: string): number {
|
||||
const m = this.meshes.get(meshId);
|
||||
if (m === undefined) return 0;
|
||||
return m.currentPeers.size / m.maxPeers;
|
||||
}
|
||||
|
||||
public getAvailableSlots(meshId: string): number {
|
||||
const m = this.meshes.get(meshId);
|
||||
if (m === undefined) return 0;
|
||||
return Math.max(0, m.maxPeers - m.currentPeers.size);
|
||||
}
|
||||
|
||||
public listMeshes(): readonly string[] {
|
||||
return [...this.meshes.keys()];
|
||||
}
|
||||
|
||||
public getMaxPeers(meshId: string): number {
|
||||
return this.meshes.get(meshId)?.maxPeers ?? 0;
|
||||
}
|
||||
|
||||
public on(listener: CapacityListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private upsert(meshId: string): {
|
||||
maxPeers: number;
|
||||
currentPeers: Set<string>;
|
||||
} {
|
||||
const existing = this.meshes.get(meshId);
|
||||
if (existing !== undefined) return existing;
|
||||
const m = {
|
||||
maxPeers: this.defaultMaxPeers,
|
||||
currentPeers: new Set<string>(),
|
||||
};
|
||||
this.meshes.set(meshId, m);
|
||||
this.fullState.set(meshId, false);
|
||||
return m;
|
||||
}
|
||||
|
||||
private recheckFull(meshId: string): void {
|
||||
const m = this.meshes.get(meshId)!;
|
||||
const wasFull = this.fullState.get(meshId) === true;
|
||||
const isFull = m.currentPeers.size >= m.maxPeers;
|
||||
if (isFull && !wasFull) {
|
||||
this.fullState.set(meshId, true);
|
||||
this.fire("mesh:full", meshId);
|
||||
} else if (!isFull && wasFull) {
|
||||
this.fullState.set(meshId, false);
|
||||
this.fire("mesh:available", meshId);
|
||||
}
|
||||
}
|
||||
|
||||
private fire(event: "mesh:full" | "mesh:available", meshId: string): void {
|
||||
for (const l of this.listeners) l(event, meshId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MeshCapacityTracker } from "./mesh-capacity-tracker.js";
|
||||
|
||||
describe("MeshCapacityTracker", () => {
|
||||
it("records mesh, joins and tracks utilization", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 10);
|
||||
t.recordJoin("mesh-A", "alice");
|
||||
t.recordJoin("mesh-A", "bob");
|
||||
expect(t.getUtilization("mesh-A")).toBe(0.2);
|
||||
expect(t.getAvailableSlots("mesh-A")).toBe(8);
|
||||
expect(t.getMaxPeers("mesh-A")).toBe(10);
|
||||
});
|
||||
|
||||
it("returns zero utilization for unknown meshes", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
expect(t.getUtilization("ghost")).toBe(0);
|
||||
expect(t.getAvailableSlots("ghost")).toBe(0);
|
||||
expect(t.getMaxPeers("ghost")).toBe(0);
|
||||
});
|
||||
|
||||
it("autocreates mesh when join arrives first", () => {
|
||||
const t = new MeshCapacityTracker({ defaultMaxPeers: 5 });
|
||||
t.recordJoin("mesh-X", "alice");
|
||||
expect(t.getMaxPeers("mesh-X")).toBe(5);
|
||||
});
|
||||
|
||||
it("recordLeave decrements and unknown meshes are no-op", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 5);
|
||||
t.recordJoin("mesh-A", "alice");
|
||||
t.recordLeave("mesh-A", "alice");
|
||||
t.recordLeave("ghost", "ghost");
|
||||
expect(t.getUtilization("mesh-A")).toBe(0);
|
||||
});
|
||||
|
||||
it("fires mesh:full / mesh:available transitions", () => {
|
||||
const events: string[] = [];
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 2);
|
||||
t.on((e, m) => events.push(`${e}::${m}`));
|
||||
t.recordJoin("mesh-A", "alice");
|
||||
expect(events).toEqual([]);
|
||||
t.recordJoin("mesh-A", "bob");
|
||||
expect(events).toEqual(["mesh:full::mesh-A"]);
|
||||
t.recordLeave("mesh-A", "alice");
|
||||
expect(events).toEqual(["mesh:full::mesh-A", "mesh:available::mesh-A"]);
|
||||
});
|
||||
|
||||
it("on() returns an unsubscribe handle", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A", 1);
|
||||
const events: string[] = [];
|
||||
const off = t.on((e) => events.push(e));
|
||||
off();
|
||||
t.recordJoin("mesh-A", "alice");
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("recordMesh upserts capacity and rechecks full state", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
const events: string[] = [];
|
||||
t.recordMesh("mesh-A", 1);
|
||||
t.on((e) => events.push(e));
|
||||
t.recordJoin("mesh-A", "alice");
|
||||
// mesh full at 1/1
|
||||
expect(events).toContain("mesh:full");
|
||||
t.recordMesh("mesh-A", 10); // expand → no longer full
|
||||
expect(events).toContain("mesh:available");
|
||||
});
|
||||
|
||||
it("listMeshes returns every known mesh", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A");
|
||||
t.recordMesh("mesh-B");
|
||||
expect(t.listMeshes().sort()).toEqual(["mesh-A", "mesh-B"]);
|
||||
});
|
||||
|
||||
it("uses default maxPeers when not specified on recordMesh", () => {
|
||||
const t = new MeshCapacityTracker({ defaultMaxPeers: 7 });
|
||||
t.recordMesh("mesh-A");
|
||||
expect(t.getMaxPeers("mesh-A")).toBe(7);
|
||||
});
|
||||
|
||||
it("uses 64 as the built-in default", () => {
|
||||
const t = new MeshCapacityTracker();
|
||||
t.recordMesh("mesh-A");
|
||||
expect(t.getMaxPeers("mesh-A")).toBe(64);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-plugin-assignment dep-graph", () => {
|
||||
it("loads index.js (acyclic static imports)", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod.MeshCapacityTracker).toBeDefined();
|
||||
expect(mod.AssignmentEngine).toBeDefined();
|
||||
expect(mod.MeshBalancer).toBeDefined();
|
||||
expect(mod.AdmissionController).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AdmissionController,
|
||||
AssignmentEngine,
|
||||
MeshBalancer,
|
||||
MeshCapacityTracker,
|
||||
type MeshDescriptor,
|
||||
type PeerInfo,
|
||||
} from "../../src/index.js";
|
||||
|
||||
describe("assignment end-to-end", () => {
|
||||
it("admits peers, builds a rebalance plan, and applies moves", () => {
|
||||
const tracker = new MeshCapacityTracker();
|
||||
tracker.recordMesh("mesh-A", 10);
|
||||
tracker.recordMesh("mesh-B", 10);
|
||||
|
||||
const meshes: MeshDescriptor[] = [
|
||||
{ meshId: "mesh-A", region: "us-east", tierFloor: 0 },
|
||||
{ meshId: "mesh-B", region: "us-west", tierFloor: 0 },
|
||||
];
|
||||
|
||||
const engine = new AssignmentEngine({ tracker });
|
||||
const controller = new AdmissionController({
|
||||
engine,
|
||||
tracker,
|
||||
meshes: () => meshes,
|
||||
});
|
||||
|
||||
// Force imbalance by manually loading mesh-A.
|
||||
for (let i = 0; i < 8; i++) tracker.recordJoin("mesh-A", `p${i}`);
|
||||
|
||||
const balancer = new MeshBalancer({
|
||||
tracker,
|
||||
imbalanceThreshold: 0.3,
|
||||
maxMovesPerPlan: 2,
|
||||
});
|
||||
const plan = balancer.buildPlan((overloaded) => {
|
||||
expect(overloaded).toBe("mesh-A");
|
||||
return ["p0", "p1"];
|
||||
});
|
||||
expect(plan.moves).toHaveLength(2);
|
||||
for (const move of plan.moves) {
|
||||
tracker.recordLeave(move.fromMeshId, move.peerId);
|
||||
tracker.recordJoin(move.toMeshId, move.peerId);
|
||||
}
|
||||
expect(tracker.getUtilization("mesh-A")).toBe(0.6);
|
||||
expect(tracker.getUtilization("mesh-B")).toBe(0.2);
|
||||
|
||||
// A fresh peer can still be admitted via the controller.
|
||||
const fresh: PeerInfo = {
|
||||
peerId: "alice",
|
||||
latencyMap: new Map([
|
||||
["mesh-A", 50],
|
||||
["mesh-B", 50],
|
||||
]),
|
||||
region: "us-east",
|
||||
tierEligibility: 1,
|
||||
capabilities: "STANDARD",
|
||||
};
|
||||
const result = controller.admit(fresh);
|
||||
expect(["mesh-A", "mesh-B"]).toContain(result.meshId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
AssignmentEngine,
|
||||
MeshCapacityTracker,
|
||||
type MeshDescriptor,
|
||||
type PeerInfo,
|
||||
} from "../../src/index.js";
|
||||
|
||||
describe("AssignmentEngine scoring is deterministic", () => {
|
||||
it("same input twice produces identical scores", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.tuple(
|
||||
fc.string({ minLength: 1, maxLength: 6 }),
|
||||
fc.integer({ min: 1, max: 100 }),
|
||||
fc.integer({ min: 0, max: 5 })
|
||||
),
|
||||
{ minLength: 1, maxLength: 5 }
|
||||
),
|
||||
fc.integer({ min: 0, max: 5 }),
|
||||
(entries, tierEligibility) => {
|
||||
const seen = new Set<string>();
|
||||
const meshes: MeshDescriptor[] = [];
|
||||
const latencyMap = new Map<string, number>();
|
||||
for (const [name, latency, tierFloor] of entries) {
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
meshes.push({ meshId: name, tierFloor });
|
||||
latencyMap.set(name, latency);
|
||||
}
|
||||
if (meshes.length === 0) return;
|
||||
const peer: PeerInfo = {
|
||||
peerId: "p",
|
||||
latencyMap,
|
||||
tierEligibility,
|
||||
capabilities: "STANDARD",
|
||||
};
|
||||
const tracker = new MeshCapacityTracker();
|
||||
for (const m of meshes) tracker.recordMesh(m.meshId, 10);
|
||||
const engine = new AssignmentEngine({ tracker });
|
||||
const a = engine.score(peer, meshes);
|
||||
const b = engine.score(peer, meshes);
|
||||
expect(a.map((s) => s.score)).toEqual(
|
||||
b.map((s) => s.score)
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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-assignment",
|
||||
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