204 lines
7.2 KiB
Markdown
204 lines
7.2 KiB
Markdown
# OpenSpec Change: network-plugin-assignment
|
||
|
||
- **Change ID**: network-plugin-assignment
|
||
- **Package**: `@suaveplan/network-plugin-assignment` (new)
|
||
- **Status**: PROPOSED
|
||
- **Date**: 2026-05-01
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
Implement mesh auto-assignment and load balancing. When a new peer connects to the system, there is currently no algorithm to decide which mesh it should join. This plugin provides the "placement layer": tracking mesh capacity across the fleet, scoring candidate meshes for a new peer, rebalancing peers when meshes become overloaded, and admitting or rejecting connections based on global capacity.
|
||
|
||
This is a P0 gap blocking the user's stated requirement: *"automatically place a new connection in a tiered mesh with available spaces."*
|
||
|
||
---
|
||
|
||
## Why
|
||
|
||
**User requirement**: *"automatically place a new connection in a tiered mesh with available spaces"*
|
||
|
||
**Current state**: `enterprise-network-assignment` (dated 2026-04-24) provides consistent hashing and rendezvous hashing for **peer selection within a mesh**. It does NOT assign a new peer **to a mesh**.
|
||
|
||
**What's missing**:
|
||
|
||
- Mesh capacity tracking across the fleet
|
||
- Algorithm for placing a new peer into the "best" mesh (latency, geography, capacity, load)
|
||
- Rebalancing when meshes become imbalanced
|
||
- Handling of "mesh full" scenarios
|
||
|
||
---
|
||
|
||
## Core components
|
||
|
||
### 1. `MeshCapacityTracker`
|
||
|
||
Consumes `peer:joined` / `peer:left` / `peer:updated` events from `network-plugin-registry` to maintain a live view of `currentPeers / maxPeers` per mesh.
|
||
|
||
```ts
|
||
class MeshCapacityTracker {
|
||
recordMesh(meshId: string, maxPeers: number): void;
|
||
recordJoin(meshId: string, peerId: string): void;
|
||
recordLeave(meshId: string, peerId: string): void;
|
||
getUtilization(meshId: string): number; // 0.0 – 1.0
|
||
getAvailableSlots(meshId: string): number;
|
||
listMeshes(): string[];
|
||
on(event: "mesh:full" | "mesh:available", handler: (meshId: string) => void): void;
|
||
}
|
||
```
|
||
|
||
### 2. `AssignmentEngine`
|
||
|
||
Scores candidate meshes for a new peer and returns the best assignment.
|
||
|
||
```ts
|
||
interface PeerInfo {
|
||
peerId: string;
|
||
latencyMap: Map<string, number>; // meshId → RTT ms
|
||
region?: string;
|
||
tierEligibility: number; // max tier this peer can reach (0, 1, 2)
|
||
capabilities: DeviceProfile; // CONSTRAINED, STANDARD, POWERFUL, SERVER
|
||
}
|
||
|
||
interface AssignmentScore {
|
||
meshId: string;
|
||
score: number; // higher is better
|
||
factors: {
|
||
latencyScore: number;
|
||
capacityScore: number;
|
||
loadScore: number;
|
||
regionScore: number;
|
||
tierFitScore: number;
|
||
};
|
||
}
|
||
|
||
class AssignmentEngine {
|
||
constructor(options: {
|
||
tracker: MeshCapacityTracker;
|
||
weights: AssignmentWeights; // configurable
|
||
minScoreThreshold: number;
|
||
});
|
||
assign(peer: PeerInfo): AssignmentScore | null; // null = no suitable mesh
|
||
rankCandidates(peer: PeerInfo): AssignmentScore[];
|
||
}
|
||
```
|
||
|
||
**Scoring factors** (weights sum to 1.0, configurable):
|
||
|
||
- **Latency** (default 0.30): lower RTT to mesh host = higher score
|
||
- **Capacity** (default 0.25): more available slots = higher score
|
||
- **Load** (default 0.20): lower current CPU/bandwidth load = higher score
|
||
- **Region** (default 0.15): same region as peer = higher score
|
||
- **Tier fit** (default 0.10): mesh needs peers of this peer's capability level
|
||
|
||
**Fallback**: If all meshes are full, the engine returns `null`. The caller (`AdmissionController`) may then trigger mesh creation or reject the peer.
|
||
|
||
### 3. `MeshBalancer`
|
||
|
||
Periodic rebalancer that moves peers from overloaded meshes to underloaded ones.
|
||
|
||
```ts
|
||
class MeshBalancer {
|
||
constructor(options: {
|
||
tracker: MeshCapacityTracker;
|
||
imbalanceThreshold: number; // e.g. 0.20 = 20% variance triggers rebalance
|
||
rebalanceIntervalMs: number;
|
||
maxMovesPerCycle: number;
|
||
});
|
||
start(): void;
|
||
stop(): void;
|
||
evaluate(): RebalancePlan[];
|
||
executePlan(plan: RebalancePlan): Promise<void>;
|
||
}
|
||
|
||
interface RebalancePlan {
|
||
peerId: string;
|
||
fromMeshId: string;
|
||
toMeshId: string;
|
||
reason: string;
|
||
}
|
||
```
|
||
|
||
Rebalancing is **cooperative**: the target mesh must accept the peer, and the peer must confirm the move. Uses `network-plugin-migration` for the actual state transfer.
|
||
|
||
### 4. `AdmissionController`
|
||
|
||
Gatekeeper that accepts or rejects incoming peer connections.
|
||
|
||
```ts
|
||
class AdmissionController {
|
||
constructor(options: {
|
||
engine: AssignmentEngine;
|
||
balancer: MeshBalancer;
|
||
allowMeshCreation: boolean; // if true, creates new mesh when all full
|
||
globalMaxPeers: number;
|
||
});
|
||
admit(peer: PeerInfo): AdmissionResult;
|
||
}
|
||
|
||
interface AdmissionResult {
|
||
accepted: boolean;
|
||
meshId?: string;
|
||
reason?: string;
|
||
alternativeMeshIds?: string[]; // ranked alternatives if primary is full
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Error codes
|
||
|
||
```ts
|
||
const ASSIGN_CODES = defineCodes("ASSIGN", [
|
||
"MESH_FULL",
|
||
"NO_MESH_AVAILABLE",
|
||
"GLOBAL_CAPACITY_EXCEEDED",
|
||
"PEER_REJECTED",
|
||
"REBALANCE_FAILED",
|
||
"TRACKER_SYNC_ERROR",
|
||
"INVALID_PEER_INFO",
|
||
] as const);
|
||
```
|
||
|
||
`AssignmentError extends SuaveplanError<AssignmentErrorCode>`.
|
||
|
||
---
|
||
|
||
## Dependencies
|
||
|
||
- **Consumes**: `@suaveplan/network` (`NetworkNode`, `NetworkBus`), `@suaveplan/network-plugin-registry` (peer events for capacity tracking), `@suaveplan/network-plugin-topology` (tier eligibility, region data), `@suaveplan/network-plugin-relay` (cross-mesh routing for rebalanced peers), `@suaveplan/network-plugin-migration` (state transfer during rebalance), `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`.
|
||
- **Blocked on**: `network-core` (shipped), `network-plugin-registry`, `network-plugin-topology`.
|
||
|
||
---
|
||
|
||
## Verification approach
|
||
|
||
- `src/tracker/tracker.spec-conformance.test.ts` — pins capacity tracking, utilization math, full/available events.
|
||
- `src/engine/engine.spec-conformance.test.ts` — pins scoring algorithm, weights, fallback behaviour, tier fit.
|
||
- `src/balancer/balancer.spec-conformance.test.ts` — pins imbalance detection, plan generation, maxMovesPerCycle.
|
||
- `src/controller/controller.spec-conformance.test.ts` — pins admit/reject paths, global capacity enforcement.
|
||
- `tests/integration/assignment.spec-conformance.test.ts` — end-to-end: 3 meshes, 10 peers, verify balanced distribution.
|
||
- Property-based test: random peer latencies/regions/capabilities → assignment is deterministic for same inputs.
|
||
- 100/100/100/100 Istanbul coverage.
|
||
|
||
---
|
||
|
||
## Conformance requirements (proposed)
|
||
|
||
- `NET-71` — `MeshCapacityTracker` tracks `currentPeers / maxPeers` per mesh from registry events.
|
||
- `NET-72` — `AssignmentEngine` scores candidate meshes using latency, capacity, load, region, and tier fit.
|
||
- `NET-73` — `AssignmentEngine` returns `null` when no suitable mesh is found.
|
||
- `NET-74` — `MeshBalancer` detects imbalance and generates `RebalancePlan` objects.
|
||
- `NET-75` — `AdmissionController` rejects peers when global capacity is exceeded.
|
||
- `NET-76` — `AdmissionController` returns ranked alternative meshes when the primary mesh is full.
|
||
- `NET-77` — Rebalancing triggers `network-plugin-migration` for peer state transfer.
|
||
|
||
---
|
||
|
||
## Out of scope
|
||
|
||
- **Global DHT for mesh discovery** → handled by `network-plugin-relay` `MeshDirectory`.
|
||
- **Forced peer eviction** → v2; v1 only does cooperative rebalancing.
|
||
- **Real-time load metrics (CPU/bandwidth)** → consumes from `network-plugin-monitor` when available; v1 uses peer count as load proxy.
|