Initial Commit
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { QualityTrendAnalyzer } from "./quality-trend-analyzer.js";
|
||||
|
||||
describe("NET-46 — QualityTrendAnalyzer slope detection", () => {
|
||||
it("first sample never produces a degrading flag (no prior delta)", () => {
|
||||
const a = new QualityTrendAnalyzer();
|
||||
const trend = a.record("ws", {
|
||||
latency: 50,
|
||||
loss: 0,
|
||||
bandwidthBps: 1_000_000,
|
||||
});
|
||||
expect(trend.degrading).toBe(false);
|
||||
});
|
||||
|
||||
it("rising latency past threshold flips degrading", () => {
|
||||
const a = new QualityTrendAnalyzer({
|
||||
latencyBadSlope: 5,
|
||||
emaAlpha: 1,
|
||||
});
|
||||
a.record("ws", { latency: 50, loss: 0, bandwidthBps: 1_000_000 });
|
||||
const trend = a.record("ws", {
|
||||
latency: 80,
|
||||
loss: 0,
|
||||
bandwidthBps: 1_000_000,
|
||||
});
|
||||
expect(trend.latencySlope).toBeGreaterThanOrEqual(5);
|
||||
expect(trend.degrading).toBe(true);
|
||||
});
|
||||
|
||||
it("rising loss past threshold flips degrading", () => {
|
||||
const a = new QualityTrendAnalyzer({
|
||||
lossBadSlope: 0.005,
|
||||
emaAlpha: 1,
|
||||
});
|
||||
a.record("ws", { latency: 50, loss: 0, bandwidthBps: 1_000_000 });
|
||||
const trend = a.record("ws", {
|
||||
latency: 50,
|
||||
loss: 0.02,
|
||||
bandwidthBps: 1_000_000,
|
||||
});
|
||||
expect(trend.degrading).toBe(true);
|
||||
});
|
||||
|
||||
it("falling bandwidth past threshold flips degrading", () => {
|
||||
const a = new QualityTrendAnalyzer({
|
||||
bandwidthBadSlope: -1024,
|
||||
emaAlpha: 1,
|
||||
});
|
||||
a.record("ws", { latency: 50, loss: 0, bandwidthBps: 1_000_000 });
|
||||
const trend = a.record("ws", {
|
||||
latency: 50,
|
||||
loss: 0,
|
||||
bandwidthBps: 990_000,
|
||||
});
|
||||
expect(trend.degrading).toBe(true);
|
||||
});
|
||||
|
||||
it("rolling window keeps the last N samples", () => {
|
||||
const a = new QualityTrendAnalyzer({ windowSize: 3 });
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
a.record("ws", {
|
||||
latency: i,
|
||||
loss: 0,
|
||||
bandwidthBps: 1_000_000,
|
||||
});
|
||||
}
|
||||
const window = a.window("ws");
|
||||
expect(window.latency.length).toBe(3);
|
||||
expect(window.latency).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it("reset(id) drops a single window; reset() drops all", () => {
|
||||
const a = new QualityTrendAnalyzer();
|
||||
a.record("ws", { latency: 50, loss: 0, bandwidthBps: 1_000_000 });
|
||||
a.record("wrtc", { latency: 80, loss: 0, bandwidthBps: 1_000_000 });
|
||||
a.reset("ws");
|
||||
expect(a.window("ws").latency.length).toBe(0);
|
||||
expect(a.window("wrtc").latency.length).toBe(1);
|
||||
a.reset();
|
||||
expect(a.window("wrtc").latency.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
# `QualityTrendAnalyzer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Tracks short rolling windows of latency, packet loss, and bandwidth per transport and reports a smoothed slope over each metric. Pre-emptive switching uses the slope (not the absolute value) to predict failure: a transport at 80 ms RTT that is climbing 10 ms/sample is failing faster than one at 200 ms RTT that is flat.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-metric slope via exponentially-weighted moving average of per-step deltas (cheaper than re-fitting a regression on every sample).
|
||||
- Per-transport rolling window (default size 16) so callers can introspect the recent history for charts and diagnostics.
|
||||
- Configurable `bad` thresholds per metric so different deployments (mobile vs server) can tune sensitivity.
|
||||
- `degrading: boolean` summary flag that the pre-emptive selector consults directly — no need to re-implement threshold logic in callers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { QualityTrendAnalyzer } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
analyzer.record("ws", { latency: 60, loss: 0, bandwidthBps: 1_000_000 });
|
||||
const trend = analyzer.record("ws", {
|
||||
latency: 90,
|
||||
loss: 0.02,
|
||||
bandwidthBps: 500_000,
|
||||
});
|
||||
trend.degrading; // likely true — latency and loss climbing, bandwidth dropping
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { QualityTrendAnalyzer } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const analyzer = new QualityTrendAnalyzer({
|
||||
windowSize: 32,
|
||||
latencyBadSlope: 10,
|
||||
lossBadSlope: 0.01,
|
||||
bandwidthBadSlope: -2048,
|
||||
emaAlpha: 0.5,
|
||||
});
|
||||
|
||||
// Feed a slow degradation pattern
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
analyzer.record("wrtc", {
|
||||
latency: 50 + i * 6,
|
||||
loss: i * 0.005,
|
||||
bandwidthBps: 2_000_000 - i * 1500,
|
||||
});
|
||||
}
|
||||
const summary = analyzer.window("wrtc");
|
||||
// summary.latency, summary.loss, summary.bandwidthBps each carry the
|
||||
// last 12 samples in arrival order.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class QualityTrendAnalyzer(config?)`
|
||||
- `record(transportId, { latency, loss, bandwidthBps }): QualityTrend` — append a sample, update slopes, return the latest trend snapshot.
|
||||
- `window(transportId): QualityWindow` — frozen-style snapshot of the rolling buffers (cloned).
|
||||
- `reset(transportId?): void` — drop a single transport's window or all of them.
|
||||
- `interface QualityTrend` — `{ transportId, latencySlope, lossSlope, bandwidthSlope, degrading }`.
|
||||
- `interface QualityWindow` — `{ transportId, latency, loss, bandwidthBps }`.
|
||||
- `interface QualityTrendAnalyzerConfig` — `{ windowSize?, latencyBadSlope?, lossBadSlope?, bandwidthBadSlope?, emaAlpha? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Slopes are computed from the _delta_ between this and the previous sample, then EMA-smoothed. This is O(1) per sample and avoids storing the full window for the slope calculation, even though the window itself is kept for diagnostic introspection.
|
||||
- Bandwidth slope is "bad" when it goes more negative than the threshold (default `-1024` bps/sample) — i.e. throughput is collapsing.
|
||||
- The first sample for a transport never produces a slope (no prior delta), so `degrading` is always `false` until at least the second `record` call. Tests pin this boundary explicitly.
|
||||
- The analyzer does not enforce monotonicity or quantize timestamps — callers feed samples at whatever cadence their transport monitor produces them. Slope is "per sample", not "per second", which keeps the analyzer agnostic to sampling rate.
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Per-transport rolling-window samples used to derive trend slopes.
|
||||
*/
|
||||
export interface QualityWindow {
|
||||
readonly transportId: string;
|
||||
readonly latency: ReadonlyArray<number>;
|
||||
readonly loss: ReadonlyArray<number>;
|
||||
readonly bandwidthBps: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
export interface QualityTrend {
|
||||
readonly transportId: string;
|
||||
/** Positive slope = latency increasing (bad). */
|
||||
readonly latencySlope: number;
|
||||
/** Positive slope = loss increasing (bad). */
|
||||
readonly lossSlope: number;
|
||||
/** Negative slope = bandwidth degrading (bad). */
|
||||
readonly bandwidthSlope: number;
|
||||
/** True when any of the three slopes crossed its `bad` threshold. */
|
||||
readonly degrading: boolean;
|
||||
}
|
||||
|
||||
export interface QualityTrendAnalyzerConfig {
|
||||
/** How many samples each rolling window keeps. Defaults to 16. */
|
||||
readonly windowSize?: number;
|
||||
/** Latency slope (ms/sample) considered degrading. Defaults to +5. */
|
||||
readonly latencyBadSlope?: number;
|
||||
/** Loss slope per sample considered degrading. Defaults to +0.005. */
|
||||
readonly lossBadSlope?: number;
|
||||
/** Bandwidth slope (bps/sample) considered degrading. Defaults to -1024. */
|
||||
readonly bandwidthBadSlope?: number;
|
||||
/** EMA alpha for slope smoothing. Defaults to 0.4. */
|
||||
readonly emaAlpha?: number;
|
||||
}
|
||||
|
||||
interface MutableWindow {
|
||||
latency: number[];
|
||||
loss: number[];
|
||||
bandwidthBps: number[];
|
||||
latencySlope: number;
|
||||
lossSlope: number;
|
||||
bandwidthSlope: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* EMA-of-deltas trend analyzer. Each `record` call appends a sample to
|
||||
* the per-transport rolling window and updates an exponentially-weighted
|
||||
* moving average of the per-step delta, which is a stable proxy for
|
||||
* "slope" without needing to fit a regression on every call.
|
||||
*/
|
||||
export class QualityTrendAnalyzer {
|
||||
private readonly windows = new Map<string, MutableWindow>();
|
||||
private readonly windowSize: number;
|
||||
private readonly latencyBadSlope: number;
|
||||
private readonly lossBadSlope: number;
|
||||
private readonly bandwidthBadSlope: number;
|
||||
private readonly emaAlpha: number;
|
||||
|
||||
public constructor(config: QualityTrendAnalyzerConfig = {}) {
|
||||
this.windowSize = config.windowSize ?? 16;
|
||||
this.latencyBadSlope = config.latencyBadSlope ?? 5;
|
||||
this.lossBadSlope = config.lossBadSlope ?? 0.005;
|
||||
this.bandwidthBadSlope = config.bandwidthBadSlope ?? -1024;
|
||||
this.emaAlpha = config.emaAlpha ?? 0.4;
|
||||
}
|
||||
|
||||
public record(
|
||||
transportId: string,
|
||||
sample: { latency: number; loss: number; bandwidthBps: number }
|
||||
): QualityTrend {
|
||||
const window = this.windowFor(transportId);
|
||||
const prevLatency = lastOf(window.latency);
|
||||
const prevLoss = lastOf(window.loss);
|
||||
const prevBandwidth = lastOf(window.bandwidthBps);
|
||||
|
||||
push(window.latency, sample.latency, this.windowSize);
|
||||
push(window.loss, sample.loss, this.windowSize);
|
||||
push(window.bandwidthBps, sample.bandwidthBps, this.windowSize);
|
||||
|
||||
if (prevLatency !== undefined) {
|
||||
window.latencySlope = ema(
|
||||
window.latencySlope,
|
||||
sample.latency - prevLatency,
|
||||
this.emaAlpha
|
||||
);
|
||||
}
|
||||
if (prevLoss !== undefined) {
|
||||
window.lossSlope = ema(
|
||||
window.lossSlope,
|
||||
sample.loss - prevLoss,
|
||||
this.emaAlpha
|
||||
);
|
||||
}
|
||||
if (prevBandwidth !== undefined) {
|
||||
window.bandwidthSlope = ema(
|
||||
window.bandwidthSlope,
|
||||
sample.bandwidthBps - prevBandwidth,
|
||||
this.emaAlpha
|
||||
);
|
||||
}
|
||||
|
||||
const degrading =
|
||||
window.latencySlope >= this.latencyBadSlope ||
|
||||
window.lossSlope >= this.lossBadSlope ||
|
||||
window.bandwidthSlope <= this.bandwidthBadSlope;
|
||||
|
||||
return {
|
||||
transportId,
|
||||
latencySlope: window.latencySlope,
|
||||
lossSlope: window.lossSlope,
|
||||
bandwidthSlope: window.bandwidthSlope,
|
||||
degrading,
|
||||
};
|
||||
}
|
||||
|
||||
public window(transportId: string): QualityWindow {
|
||||
const w = this.windowFor(transportId);
|
||||
return {
|
||||
transportId,
|
||||
latency: [...w.latency],
|
||||
loss: [...w.loss],
|
||||
bandwidthBps: [...w.bandwidthBps],
|
||||
};
|
||||
}
|
||||
|
||||
public reset(transportId?: string): void {
|
||||
if (transportId === undefined) {
|
||||
this.windows.clear();
|
||||
return;
|
||||
}
|
||||
this.windows.delete(transportId);
|
||||
}
|
||||
|
||||
private windowFor(transportId: string): MutableWindow {
|
||||
let window = this.windows.get(transportId);
|
||||
if (window === undefined) {
|
||||
window = {
|
||||
latency: [],
|
||||
loss: [],
|
||||
bandwidthBps: [],
|
||||
latencySlope: 0,
|
||||
lossSlope: 0,
|
||||
bandwidthSlope: 0,
|
||||
};
|
||||
this.windows.set(transportId, window);
|
||||
}
|
||||
return window;
|
||||
}
|
||||
}
|
||||
|
||||
function push(buf: number[], value: number, max: number): void {
|
||||
buf.push(value);
|
||||
if (buf.length > max) buf.shift();
|
||||
}
|
||||
|
||||
function lastOf(buf: ReadonlyArray<number>): number | undefined {
|
||||
if (buf.length === 0) return undefined;
|
||||
return buf[buf.length - 1];
|
||||
}
|
||||
|
||||
function ema(prev: number, sample: number, alpha: number): number {
|
||||
return prev * (1 - alpha) + sample * alpha;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TM_CODES, type NetworkTmError } from "../error-codes.js";
|
||||
import { TransportPool } from "../pool/transport-pool.js";
|
||||
import type { TransportLike } from "../types/transport.types.js";
|
||||
import { RoundRobinBalancer } from "./round-robin-balancer.js";
|
||||
|
||||
const t = (
|
||||
id: string,
|
||||
capability: TransportLike["capability"]
|
||||
): TransportLike => ({ id, capability, connected: true });
|
||||
|
||||
describe("NET-26 — RoundRobinBalancer distributes load deterministically", () => {
|
||||
it("rotates through every healthy transport for the same capability", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("ws-a", "websocket"));
|
||||
pool.register(t("ws-b", "websocket"));
|
||||
pool.register(t("ws-c", "websocket"));
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
const sequence = balancer.preview(["websocket"], 6);
|
||||
expect(sequence).toEqual([
|
||||
"ws-a",
|
||||
"ws-b",
|
||||
"ws-c",
|
||||
"ws-a",
|
||||
"ws-b",
|
||||
"ws-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats reordered capability lists as the same cursor", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
pool.register(t("ws", "websocket"));
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
const a = balancer.next(["webrtc-datachannel", "websocket"]).id;
|
||||
const b = balancer.next(["websocket", "webrtc-datachannel"]).id;
|
||||
expect([a, b]).toEqual(["wrtc", "ws"]);
|
||||
});
|
||||
|
||||
it("rejects when no transport matches with NO_HEALTHY_TRANSPORT", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("ws", "websocket"));
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
try {
|
||||
balancer.next(["bluetooth-le"]);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.NO_HEALTHY_TRANSPORT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reset clears every cursor", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("ws-a", "websocket"));
|
||||
pool.register(t("ws-b", "websocket"));
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
balancer.next(["websocket"]); // ws-a
|
||||
balancer.reset();
|
||||
expect(balancer.next(["websocket"]).id).toBe("ws-a");
|
||||
});
|
||||
|
||||
it("candidates filters disconnected transports", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register({
|
||||
id: "ws-on",
|
||||
capability: "websocket",
|
||||
connected: true,
|
||||
});
|
||||
pool.register({
|
||||
id: "ws-off",
|
||||
capability: "websocket",
|
||||
connected: false,
|
||||
});
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
expect(balancer.candidates(["websocket"]).map((c) => c.id)).toEqual([
|
||||
"ws-on",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves cursor across calls", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("ws-a", "websocket"));
|
||||
pool.register(t("ws-b", "websocket"));
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
expect(balancer.next(["websocket"]).id).toBe("ws-a");
|
||||
expect(balancer.next(["websocket"]).id).toBe("ws-b");
|
||||
expect(balancer.next(["websocket"]).id).toBe("ws-a");
|
||||
});
|
||||
|
||||
it("preview reports the next n transports without resetting cursor on its own", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("ws-a", "websocket"));
|
||||
pool.register(t("ws-b", "websocket"));
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
const preview = balancer.preview(["websocket"], 3);
|
||||
expect(preview).toEqual(["ws-a", "ws-b", "ws-a"]);
|
||||
// Next live call continues the cursor (preview advanced it 3 times).
|
||||
expect(balancer.next(["websocket"]).id).toBe("ws-b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
# `RoundRobinBalancer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Spreads NORMAL- and BULK-tier traffic across every healthy transport that matches the negotiated capability intersection so a single path never saturates while siblings sit idle. Pairs with the selector: the selector picks transports for redundant tiers (CRITICAL), while the balancer rotates through transports for fan-out tiers.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable round-robin cursor per capability subset — the same set of capabilities always rotates the same transport list in the same order across calls.
|
||||
- Cursor key normalises to sorted-joined form so semantically-equal capability lists share a cursor regardless of input order.
|
||||
- `preview(caps, n)` returns the next `n` transport ids without losing cursor state for tests / diagnostics.
|
||||
- Throws `NO_HEALTHY_TRANSPORT` when the candidate set is empty so callers do not silently swallow a starved capability.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
RoundRobinBalancer,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "ws-a", capability: "websocket", connected: true });
|
||||
pool.register({ id: "ws-b", capability: "websocket", connected: true });
|
||||
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
balancer.next(["websocket"]).id; // "ws-a"
|
||||
balancer.next(["websocket"]).id; // "ws-b"
|
||||
balancer.next(["websocket"]).id; // "ws-a" — wraps
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
RoundRobinBalancer,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
|
||||
// Distinct capability subsets keep separate cursors so they cannot
|
||||
// starve each other.
|
||||
balancer.preview(["webrtc-datachannel", "websocket"], 4);
|
||||
// → ["wrtc", "ws", "wrtc", "ws"]
|
||||
|
||||
balancer.preview(["websocket"], 3);
|
||||
// → ["ws", "ws", "ws"] — only one matches
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RoundRobinBalancer(pool: TransportPool)`
|
||||
- `next(commonCapabilities): TransportLike` — advances the cursor by one and returns the next eligible transport. Throws `NO_HEALTHY_TRANSPORT` when no transport matches.
|
||||
- `preview(commonCapabilities, n): ReadonlyArray<string>` — repeated `next` calls returning ids; mutates cursor state.
|
||||
- `candidates(commonCapabilities): ReadonlyArray<TransportLike>` — the eligible set (without rotating).
|
||||
- `reset(): void` — clears every cursor.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cursor is per (sorted) capability subset because two subsets with the same set of transports should still rotate independently when callers ask for them as different shapes — distinct call sites typically hit the balancer with different subsets, and sharing cursors across them would couple unrelated traffic.
|
||||
- The defensive `next === undefined` branch is unreachable when `candidates.length > 0`, but it is kept so a future change to the candidate strategy (e.g. inserting a filter that mutates the array between length-check and indexing) cannot silently return `undefined`. The test suite exercises both branches via a stub pool that simulates the race.
|
||||
- Unhealthy/disconnected transports are filtered out at every `next` call rather than cached so a transport that flips paused mid-stream is dropped on the next rotation without an explicit notification.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import type { TransportPool } from "../pool/transport-pool.js";
|
||||
import type { TransportLike } from "../types/transport.types.js";
|
||||
|
||||
/**
|
||||
* Distributes outbound NORMAL/BULK traffic across all healthy
|
||||
* transports using a stable round-robin cursor per capability subset.
|
||||
* Each call to `next(commonCapabilities)` returns the next eligible
|
||||
* transport in deterministic order.
|
||||
*/
|
||||
export class RoundRobinBalancer {
|
||||
private readonly pool: TransportPool;
|
||||
private readonly cursors = new Map<string, number>();
|
||||
|
||||
public constructor(pool: TransportPool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
public next(commonCapabilities: ReadonlyArray<string>): TransportLike {
|
||||
const candidates = this.candidates(commonCapabilities);
|
||||
if (candidates.length === 0) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.NO_HEALTHY_TRANSPORT,
|
||||
"balancer.empty",
|
||||
{
|
||||
context: { commonCapabilities: [...commonCapabilities] },
|
||||
}
|
||||
);
|
||||
}
|
||||
const key = this.cursorKey(commonCapabilities);
|
||||
const cursor = this.cursors.get(key) ?? 0;
|
||||
// candidates.length > 0 (guarded above) so the index is in-range.
|
||||
const next = candidates[cursor % candidates.length] as TransportLike;
|
||||
this.cursors.set(key, (cursor + 1) % candidates.length);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-robin distribution across `n` requests; returns the
|
||||
* sequence the balancer would emit for diagnostics or test pinning.
|
||||
*/
|
||||
public preview(
|
||||
commonCapabilities: ReadonlyArray<string>,
|
||||
n: number
|
||||
): ReadonlyArray<string> {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
out.push(this.next(commonCapabilities).id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public candidates(
|
||||
commonCapabilities: ReadonlyArray<string>
|
||||
): ReadonlyArray<TransportLike> {
|
||||
const allow = new Set(commonCapabilities);
|
||||
const out: TransportLike[] = [];
|
||||
for (const entry of this.pool.listHealthy()) {
|
||||
if (!entry.transport.connected) continue;
|
||||
if (!allow.has(entry.transport.capability)) continue;
|
||||
out.push(entry.transport);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.cursors.clear();
|
||||
}
|
||||
|
||||
private cursorKey(caps: ReadonlyArray<string>): string {
|
||||
// Sort to make `["a","b"]` and `["b","a"]` share a cursor.
|
||||
return [...caps].sort().join("|");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Error codes — `@suaveplan/network-plugin-transport-manager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of every error code raised by the transport-manager package. Codes are namespaced under `NET_TM` and registered into the global error registry on first import via the side-effect import in `src/index.ts`. Each code maps to a single failure class so that callers can branch on `error.code` without parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight codes covering registration, selection, recovery, and buffer-state failures.
|
||||
- Frozen registry — runtime mutation throws, ensuring downstream code cannot inject codes.
|
||||
- `NetworkTmError` extends `SuaveplanError<NetworkTmErrorCode>` so cause-chain traversal, redaction, and OTel adapters work without per-package wiring.
|
||||
- `NetworkTmError` preserves `cause` and supports the standard `SuaveplanErrorOptions` bag.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_TM_CODES,
|
||||
NetworkTmError,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.TRANSPORT_ALREADY_REGISTERED,
|
||||
"pool.duplicate",
|
||||
{ context: { transportId: "webrtc-1" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
try {
|
||||
pool.register(transport);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof NetworkTmError &&
|
||||
err.code === NETWORK_TM_CODES.TRANSPORT_ALREADY_REGISTERED
|
||||
) {
|
||||
// idempotent retry: skip the duplicate registration
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_TM_CODES` — frozen object mapping each code key (e.g. `TRANSPORT_ALREADY_REGISTERED`) to its fully-qualified value (`NET_TM.TRANSPORT_ALREADY_REGISTERED`). Throws on mutation attempts.
|
||||
- `NetworkTmErrorCode` — TypeScript union of every value in `NETWORK_TM_CODES`.
|
||||
- `NetworkTmError` — `SuaveplanError<NetworkTmErrorCode>` subclass. Constructor takes `(code, messageKey, options?)`. `options.context` is the canonical place to attach structured details.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is constructed via `defineCodes("NET_TM", […] as const)` so that the type system can enumerate the value union. Adding a code requires bumping the `as const` tuple — TypeScript will then propagate the new key everywhere `NetworkTmErrorCode` is referenced.
|
||||
- The `src/index.ts` barrel performs a side-effect import of `./error-codes.js` so that the registration runs even when callers tree-shake the named exports.
|
||||
- Codes are deliberately coarse (one per failure class) rather than per-call-site so consumers can build stable retry/observability policies without churn when the implementation changes.
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "./error-codes.js";
|
||||
|
||||
describe("NETWORK_TM_CODES", () => {
|
||||
it("registers under NET_TM", () => {
|
||||
for (const value of Object.values(NETWORK_TM_CODES)) {
|
||||
expect(value).toMatch(/^NET_TM\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(NETWORK_TM_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
for (const key of [
|
||||
"TRANSPORT_ALREADY_REGISTERED",
|
||||
"TRANSPORT_NOT_REGISTERED",
|
||||
"NO_COMMON_CAPABILITY",
|
||||
"NO_HEALTHY_TRANSPORT",
|
||||
"PRIORITY_UNKNOWN",
|
||||
"RECOVERY_NOT_BUFFERED",
|
||||
"BUFFER_OVERFLOW",
|
||||
"INVALID_TRANSITION",
|
||||
] as const) {
|
||||
expect(NETWORK_TM_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkTmError", () => {
|
||||
it("extends SuaveplanError", () => {
|
||||
const err = new NetworkTmError(
|
||||
NETWORK_TM_CODES.TRANSPORT_NOT_REGISTERED,
|
||||
"missing"
|
||||
);
|
||||
expect(err.name).toBe("NetworkTmError");
|
||||
expect(err.code).toBe(NETWORK_TM_CODES.TRANSPORT_NOT_REGISTERED);
|
||||
});
|
||||
|
||||
it("preserves cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new NetworkTmError(
|
||||
NETWORK_TM_CODES.PRIORITY_UNKNOWN,
|
||||
"with-cause",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of every error code thrown by
|
||||
* `@suaveplan/network-plugin-transport-manager`.
|
||||
*/
|
||||
export const NETWORK_TM_CODES = defineCodes("NET_TM", [
|
||||
"TRANSPORT_ALREADY_REGISTERED",
|
||||
"TRANSPORT_NOT_REGISTERED",
|
||||
"NO_COMMON_CAPABILITY",
|
||||
"NO_HEALTHY_TRANSPORT",
|
||||
"PRIORITY_UNKNOWN",
|
||||
"RECOVERY_NOT_BUFFERED",
|
||||
"BUFFER_OVERFLOW",
|
||||
"INVALID_TRANSITION",
|
||||
] as const);
|
||||
|
||||
export type NetworkTmErrorCode =
|
||||
(typeof NETWORK_TM_CODES)[keyof typeof NETWORK_TM_CODES];
|
||||
|
||||
export class NetworkTmError extends SuaveplanError<NetworkTmErrorCode> {
|
||||
public constructor(
|
||||
code: NetworkTmErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetworkTmError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TransportPool } from "../pool/transport-pool.js";
|
||||
import type { TransportLike } from "../types/transport.types.js";
|
||||
import { TransportHealthMonitor } from "./transport-health-monitor.js";
|
||||
|
||||
interface Event {
|
||||
event: string;
|
||||
payload: { transportId: string; score: number; paused: boolean };
|
||||
}
|
||||
|
||||
const newPool = (): TransportPool => {
|
||||
const pool = new TransportPool();
|
||||
pool.register({
|
||||
id: "ws",
|
||||
capability: "websocket",
|
||||
connected: true,
|
||||
} satisfies TransportLike);
|
||||
return pool;
|
||||
};
|
||||
|
||||
describe("Transport health monitor: degradation, pause, resume", () => {
|
||||
it("starts at score=100 and stays healthy on a clean sample", () => {
|
||||
const pool = newPool();
|
||||
const monitor = new TransportHealthMonitor(pool);
|
||||
const next = monitor.recordSample("ws", { rttMs: 30, lossRate: 0 });
|
||||
expect(next.score).toBeGreaterThan(90);
|
||||
expect(next.paused).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-pauses below pauseBelow and only resumes past resumeAtOrAbove", () => {
|
||||
const pool = newPool();
|
||||
const events: Event[] = [];
|
||||
const monitor = new TransportHealthMonitor(
|
||||
pool,
|
||||
{ emit: (event, payload) => events.push({ event, payload }) },
|
||||
{ pauseBelow: 40, resumeAtOrAbove: 70, emaAlpha: 1 }
|
||||
);
|
||||
// Drive score deeply down
|
||||
monitor.recordSample("ws", {
|
||||
rttMs: 1_000,
|
||||
lossRate: 0.7,
|
||||
dropped: true,
|
||||
});
|
||||
expect(pool.getHealth("ws").paused).toBe(true);
|
||||
// Score lands inside the [pauseBelow, resumeAtOrAbove) hysteresis band
|
||||
// → stays paused even though the absolute score is above pauseBelow.
|
||||
monitor.recordSample("ws", {
|
||||
rttMs: 200,
|
||||
lossRate: 0.15,
|
||||
dropped: false,
|
||||
});
|
||||
expect(pool.getHealth("ws").paused).toBe(true);
|
||||
// Excellent samples eventually un-pause
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
monitor.recordSample("ws", {
|
||||
rttMs: 20,
|
||||
lossRate: 0,
|
||||
dropped: false,
|
||||
});
|
||||
}
|
||||
expect(pool.getHealth("ws").paused).toBe(false);
|
||||
// We should have at least one pause and one resume event
|
||||
const pauseEvents = events.filter((e) => e.payload.paused);
|
||||
const resumeEvents = events.filter((e) => !e.payload.paused);
|
||||
expect(pauseEvents.length).toBeGreaterThan(0);
|
||||
expect(resumeEvents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("force pause/resume are idempotent and emit a single event each", () => {
|
||||
const pool = newPool();
|
||||
const events: Event[] = [];
|
||||
const monitor = new TransportHealthMonitor(pool, {
|
||||
emit: (event, payload) => events.push({ event, payload }),
|
||||
});
|
||||
monitor.pause("ws");
|
||||
monitor.pause("ws"); // no-op
|
||||
monitor.resume("ws");
|
||||
monitor.resume("ws"); // no-op
|
||||
expect(events.length).toBe(2);
|
||||
});
|
||||
|
||||
it("default null bus does not throw on transitions", () => {
|
||||
const pool = newPool();
|
||||
const monitor = new TransportHealthMonitor(pool);
|
||||
expect(() =>
|
||||
monitor.recordSample("ws", { rttMs: 0, lossRate: 0 })
|
||||
).not.toThrow();
|
||||
// Force a paused-state transition with the no-op bus to exercise
|
||||
// the `NULL_BUS.emit` no-op branch.
|
||||
expect(() => monitor.pause("ws")).not.toThrow();
|
||||
expect(() => monitor.resume("ws")).not.toThrow();
|
||||
});
|
||||
|
||||
it("stability decays on drops and recovers on healthy samples", () => {
|
||||
const pool = newPool();
|
||||
const monitor = new TransportHealthMonitor(pool, null, {
|
||||
stabilityDropStep: 0.5,
|
||||
stabilityRecoverStep: 0.1,
|
||||
emaAlpha: 0.5,
|
||||
});
|
||||
monitor.recordSample("ws", {
|
||||
rttMs: 100,
|
||||
lossRate: 0,
|
||||
dropped: true,
|
||||
});
|
||||
const after1 = pool.getHealth("ws").stability;
|
||||
expect(after1).toBeLessThan(1);
|
||||
monitor.recordSample("ws", {
|
||||
rttMs: 100,
|
||||
lossRate: 0,
|
||||
dropped: false,
|
||||
});
|
||||
expect(pool.getHealth("ws").stability).toBeGreaterThan(after1);
|
||||
});
|
||||
|
||||
it("clamps stability below 0 and lossRate below 0", () => {
|
||||
const pool = newPool();
|
||||
const monitor = new TransportHealthMonitor(pool, null, {
|
||||
stabilityDropStep: 5,
|
||||
emaAlpha: 1,
|
||||
});
|
||||
// One drop with a giant step drives stability negative pre-clamp.
|
||||
monitor.recordSample("ws", {
|
||||
rttMs: 0,
|
||||
lossRate: -1,
|
||||
dropped: true,
|
||||
});
|
||||
const health = pool.getHealth("ws");
|
||||
expect(health.stability).toBe(0);
|
||||
expect(health.lossRate).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
# `TransportHealthMonitor`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Aggregates per-transport health samples (RTT, packet loss, drop signal) into a smoothed score, persists the result on the pool, and toggles the `paused` flag with hysteresis so flapping transports do not bounce between in-service and out-of-service. The selector and balancer never need to track samples themselves — they only read the pool.
|
||||
|
||||
## Features
|
||||
|
||||
- EMA smoothing of `rttMs` and `lossRate` so a single bad sample does not pull the score off the cliff.
|
||||
- Stability tracker that decays on drops and recovers slowly with healthy samples.
|
||||
- Hysteresis: `pauseBelow` (default 30) trips a transport offline; `resumeAtOrAbove` (default 60) brings it back. Tuning these prevents oscillation.
|
||||
- Optional `BusLike` so callers can subscribe to `transport:health-change` for observability without polling the pool.
|
||||
- `pause(id)` / `resume(id)` for forced overrides on disconnect/reconnect events.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
TransportHealthMonitor,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
const monitor = new TransportHealthMonitor(pool);
|
||||
|
||||
monitor.recordSample("ws", { rttMs: 60, lossRate: 0.01 });
|
||||
pool.getHealth("ws").score; // ~95
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
TransportHealthMonitor,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const events: unknown[] = [];
|
||||
const bus = {
|
||||
emit: (event: string, payload: unknown) => events.push({ event, payload }),
|
||||
};
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
const monitor = new TransportHealthMonitor(pool, bus, {
|
||||
pauseBelow: 40,
|
||||
resumeAtOrAbove: 70,
|
||||
emaAlpha: 0.5,
|
||||
});
|
||||
|
||||
// Burst of bad samples — the score collapses past the pause threshold.
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
monitor.recordSample("wrtc", { rttMs: 600, lossRate: 0.4, dropped: true });
|
||||
}
|
||||
pool.getHealth("wrtc").paused; // true
|
||||
|
||||
// Recovery: only un-pauses when the score crosses the higher threshold.
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
monitor.recordSample("wrtc", { rttMs: 40, lossRate: 0, dropped: false });
|
||||
}
|
||||
pool.getHealth("wrtc").paused; // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransportHealthMonitor(pool, bus?, config?)`
|
||||
- `recordSample(transportId, sample): TransportHealth` — apply EMA, recompute score, toggle pause state, emit `transport:health-change` on transition.
|
||||
- `pause(transportId): void` — force pause without a sample (idempotent).
|
||||
- `resume(transportId): void` — force resume without a sample (idempotent).
|
||||
- `interface TransportHealthMonitorConfig` — `{ pauseBelow?, resumeAtOrAbove?, emaAlpha?, stabilityDropStep?, stabilityRecoverStep? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `pauseBelow` and `resumeAtOrAbove` are intentionally separated. With identical thresholds, a transport hovering at the boundary would pause/resume on every sample, generating bus storms and confusing the selector.
|
||||
- Stability decays in larger steps (default 0.1) than it recovers (default 0.05) so a transport that drops samples is treated as suspect for at least twice as many healthy samples as it took to land in suspect — matches operational intuition that "fixed once, then stayed fixed" is more credible than "one good sample".
|
||||
- The bus is optional and defaults to a no-op object so unit tests do not need to instantiate a real bus to exercise the monitor.
|
||||
- All math is in plain JavaScript numbers because the manager runs in browsers and Node — `BigInt` would be overkill and would not reflect the underlying noise.
|
||||
@@ -0,0 +1,154 @@
|
||||
import { scoreFromSample, type TransportPool } from "../pool/transport-pool.js";
|
||||
import type {
|
||||
TransportHealth,
|
||||
TransportHealthSample,
|
||||
} from "../types/transport.types.js";
|
||||
|
||||
export interface TransportHealthMonitorConfig {
|
||||
/** Below this score the transport is paused. Defaults to 30. */
|
||||
readonly pauseBelow?: number;
|
||||
/** At or above this score a paused transport resumes. Defaults to 60. */
|
||||
readonly resumeAtOrAbove?: number;
|
||||
/** EMA smoothing factor (0..1). Defaults to 0.3. */
|
||||
readonly emaAlpha?: number;
|
||||
/**
|
||||
* Stability decay step per dropped sample. Defaults to 0.1 — i.e.
|
||||
* 10 consecutive drops drives stability to 0.
|
||||
*/
|
||||
readonly stabilityDropStep?: number;
|
||||
/** Stability recovery step per healthy sample. Defaults to 0.05. */
|
||||
readonly stabilityRecoverStep?: number;
|
||||
}
|
||||
|
||||
interface BusLike {
|
||||
emit(
|
||||
event: "transport:health-change",
|
||||
payload: {
|
||||
transportId: string;
|
||||
score: number;
|
||||
paused: boolean;
|
||||
}
|
||||
): void;
|
||||
}
|
||||
|
||||
const NULL_BUS: BusLike = {
|
||||
emit: () => {
|
||||
// intentionally inert when the manager is not wired to a bus
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks per-transport health and toggles the pool's `paused` flag
|
||||
* when score crosses the configured pause/resume thresholds. Hysteresis
|
||||
* (`pauseBelow < resumeAtOrAbove`) prevents flapping.
|
||||
*/
|
||||
export class TransportHealthMonitor {
|
||||
private readonly pool: TransportPool;
|
||||
private readonly bus: BusLike;
|
||||
private readonly pauseBelow: number;
|
||||
private readonly resumeAtOrAbove: number;
|
||||
private readonly emaAlpha: number;
|
||||
private readonly stabilityDropStep: number;
|
||||
private readonly stabilityRecoverStep: number;
|
||||
|
||||
public constructor(
|
||||
pool: TransportPool,
|
||||
bus: BusLike | null = null,
|
||||
config: TransportHealthMonitorConfig = {}
|
||||
) {
|
||||
this.pool = pool;
|
||||
this.bus = bus ?? NULL_BUS;
|
||||
this.pauseBelow = config.pauseBelow ?? 30;
|
||||
this.resumeAtOrAbove = config.resumeAtOrAbove ?? 60;
|
||||
this.emaAlpha = config.emaAlpha ?? 0.3;
|
||||
this.stabilityDropStep = config.stabilityDropStep ?? 0.1;
|
||||
this.stabilityRecoverStep = config.stabilityRecoverStep ?? 0.05;
|
||||
}
|
||||
|
||||
public recordSample(
|
||||
transportId: string,
|
||||
sample: TransportHealthSample
|
||||
): TransportHealth {
|
||||
const prev = this.pool.getHealth(transportId);
|
||||
const rttMs = ema(prev.rttMs, sample.rttMs, this.emaAlpha);
|
||||
const lossRate = ema(
|
||||
prev.lossRate,
|
||||
clamp01(sample.lossRate),
|
||||
this.emaAlpha
|
||||
);
|
||||
const stability = this.adjustStability(
|
||||
prev.stability,
|
||||
sample.dropped === true
|
||||
);
|
||||
const score = scoreFromSample({ rttMs, lossRate, stability });
|
||||
const paused = this.computePauseState(prev.paused, score);
|
||||
const next: TransportHealth = {
|
||||
rttMs,
|
||||
lossRate,
|
||||
stability,
|
||||
score,
|
||||
paused,
|
||||
};
|
||||
this.pool.setHealth(transportId, next);
|
||||
if (paused !== prev.paused) {
|
||||
this.bus.emit("transport:health-change", {
|
||||
transportId,
|
||||
score,
|
||||
paused,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a transport to be paused (e.g. on `peer:disconnected`)
|
||||
* without waiting for samples to roll in.
|
||||
*/
|
||||
public pause(transportId: string): void {
|
||||
const prev = this.pool.getHealth(transportId);
|
||||
if (prev.paused) return;
|
||||
this.pool.setHealth(transportId, { ...prev, paused: true });
|
||||
this.bus.emit("transport:health-change", {
|
||||
transportId,
|
||||
score: prev.score,
|
||||
paused: true,
|
||||
});
|
||||
}
|
||||
|
||||
public resume(transportId: string): void {
|
||||
const prev = this.pool.getHealth(transportId);
|
||||
if (!prev.paused) return;
|
||||
this.pool.setHealth(transportId, { ...prev, paused: false });
|
||||
this.bus.emit("transport:health-change", {
|
||||
transportId,
|
||||
score: prev.score,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
|
||||
private adjustStability(prev: number, dropped: boolean): number {
|
||||
const next = dropped
|
||||
? prev - this.stabilityDropStep
|
||||
: prev + this.stabilityRecoverStep;
|
||||
return clamp01(next);
|
||||
}
|
||||
|
||||
private computePauseState(prevPaused: boolean, score: number): boolean {
|
||||
if (prevPaused) {
|
||||
// Only un-pause when score recovers past the higher threshold.
|
||||
return score < this.resumeAtOrAbove;
|
||||
}
|
||||
// Only pause when score falls below the lower threshold.
|
||||
return score < this.pauseBelow;
|
||||
}
|
||||
}
|
||||
|
||||
function ema(prev: number, sample: number, alpha: number): number {
|
||||
return prev * (1 - alpha) + sample * alpha;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Error codes (side-effect registration)
|
||||
import "./error-codes.js";
|
||||
|
||||
// Analyzer
|
||||
export {
|
||||
type QualityTrend,
|
||||
QualityTrendAnalyzer,
|
||||
type QualityTrendAnalyzerConfig,
|
||||
type QualityWindow,
|
||||
} from "./analyzer/quality-trend-analyzer.js";
|
||||
// Balancer
|
||||
export { RoundRobinBalancer } from "./balancer/round-robin-balancer.js";
|
||||
// Errors
|
||||
export {
|
||||
NETWORK_TM_CODES,
|
||||
NetworkTmError,
|
||||
type NetworkTmErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Health
|
||||
export {
|
||||
TransportHealthMonitor,
|
||||
type TransportHealthMonitorConfig,
|
||||
} from "./health/transport-health-monitor.js";
|
||||
// Negotiator
|
||||
export {
|
||||
CapabilityNegotiator,
|
||||
type CapabilityNegotiatorConfig,
|
||||
type NegotiatedPeer,
|
||||
} from "./negotiator/capability-negotiator.js";
|
||||
// Persistence
|
||||
export {
|
||||
type BufferedMessage,
|
||||
SessionPersistenceBuffer,
|
||||
type SessionPersistenceBufferConfig,
|
||||
} from "./persistence/session-persistence-buffer.js";
|
||||
// Plugin
|
||||
export {
|
||||
TransportManagerPlugin,
|
||||
type TransportManagerPluginConfig,
|
||||
} from "./plugin/transport-manager-plugin.js";
|
||||
// Pool
|
||||
export {
|
||||
DEFAULT_HEALTH,
|
||||
scoreFromSample,
|
||||
TransportPool,
|
||||
} from "./pool/transport-pool.js";
|
||||
// Priority
|
||||
export {
|
||||
DEFAULT_PRIORITY_CONFIG,
|
||||
PriorityRouter,
|
||||
} from "./priority/priority-router.js";
|
||||
// Recovery
|
||||
export {
|
||||
ConnectionRecoveryManager,
|
||||
type ConnectionRecoveryManagerConfig,
|
||||
type RecoverySchedule,
|
||||
} from "./recovery/connection-recovery-manager.js";
|
||||
export {
|
||||
SessionContinuityGuard,
|
||||
type SessionContinuityGuardConfig,
|
||||
type SessionState,
|
||||
} from "./recovery/session-continuity-guard.js";
|
||||
// Scheduler
|
||||
export {
|
||||
BandwidthConservator,
|
||||
type BandwidthConservatorConfig,
|
||||
type BandwidthSignal,
|
||||
} from "./scheduler/bandwidth-conservator.js";
|
||||
export {
|
||||
BatteryAwareScheduler,
|
||||
type BatteryAwareSchedulerConfig,
|
||||
type BatterySignal,
|
||||
} from "./scheduler/battery-aware-scheduler.js";
|
||||
// Selector
|
||||
export {
|
||||
type PreemptiveHandoff,
|
||||
PreemptiveSelector,
|
||||
type PreemptiveSelectorConfig,
|
||||
} from "./selector/preemptive-selector.js";
|
||||
export { TransportSelector } from "./selector/transport-selector.js";
|
||||
// Types
|
||||
export type {
|
||||
MessagePriority,
|
||||
PriorityConfig,
|
||||
} from "./types/priority.types.js";
|
||||
export type {
|
||||
SelectionResult,
|
||||
SelectorConfig,
|
||||
} from "./types/selector.types.js";
|
||||
export type {
|
||||
TransportCapability,
|
||||
TransportEntry,
|
||||
TransportHealth,
|
||||
TransportHealthSample,
|
||||
TransportLike,
|
||||
} from "./types/transport.types.js";
|
||||
@@ -0,0 +1,74 @@
|
||||
# `CapabilityNegotiator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Records the transport-capability bitmap each peer advertised during handshake and produces the intersection on demand. The selector consults this intersection so the manager only attempts transports both peers can actually speak — avoiding wasted dials over WebRTC when the remote is a Node.js worker without WebRTC support, or over Bluetooth when the remote is a browser tab.
|
||||
|
||||
## Features
|
||||
|
||||
- Stores local capabilities once at construction; remote bitmaps refresh on every `recordRemote` call.
|
||||
- Intersection respects local preference order so the selector receives a ranked list, not an unordered set.
|
||||
- Optional throw-on-empty mode (`throwOnEmpty: true`) lets callers treat zero-overlap as fatal at the boundary.
|
||||
- Clock-injectable so tests can pin `updatedAt` deterministically.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CapabilityNegotiator } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const negotiator = new CapabilityNegotiator({
|
||||
local: ["webrtc-datachannel", "websocket", "http-fetch"],
|
||||
});
|
||||
negotiator.recordRemote("peer-A", ["websocket", "http-fetch"]);
|
||||
const common = negotiator.commonWith("peer-A");
|
||||
// → ["websocket", "http-fetch"] in local preference order
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CapabilityNegotiator } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const clock = () => 1_700_000_000_000;
|
||||
const negotiator = new CapabilityNegotiator({
|
||||
local: ["webrtc-datachannel", "websocket"],
|
||||
clock,
|
||||
throwOnEmpty: true,
|
||||
});
|
||||
|
||||
// First handshake
|
||||
negotiator.recordRemote("peer-B", ["http-fetch", "bluetooth-le"]);
|
||||
try {
|
||||
negotiator.commonWith("peer-B");
|
||||
} catch (err) {
|
||||
// throws NO_COMMON_CAPABILITY — the peer has nothing in common
|
||||
}
|
||||
|
||||
// Peer reconnects via a WebRTC-capable browser tab
|
||||
negotiator.recordRemote("peer-B", ["webrtc-datachannel", "websocket"]);
|
||||
negotiator.commonWith("peer-B");
|
||||
// → ["webrtc-datachannel", "websocket"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CapabilityNegotiator`
|
||||
- `constructor(config: CapabilityNegotiatorConfig)` — `local` is the local capability list (frozen on store), `clock` is optional, `throwOnEmpty` defaults to `false`.
|
||||
- `localCapabilities(): ReadonlyArray<TransportCapability>` — frozen copy of the configured local list.
|
||||
- `recordRemote(peerId, capabilities): NegotiatedPeer` — replaces any prior bitmap and stamps `updatedAt`.
|
||||
- `hasRemote(peerId): boolean`.
|
||||
- `forgetRemote(peerId): void`.
|
||||
- `clear(): void`.
|
||||
- `commonWith(peerId): ReadonlyArray<TransportCapability>` — returns the intersection in local preference order; throws `NO_COMMON_CAPABILITY` when empty if `throwOnEmpty: true`.
|
||||
- `interface NegotiatedPeer` — `{ peerId, capabilities, updatedAt }`.
|
||||
- `interface CapabilityNegotiatorConfig` — `{ local, clock?, throwOnEmpty? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The intersection is computed with a `Set` lookup (O(n + m)) where `n` = local list and `m` = remote list. Both are short, so this is effectively constant.
|
||||
- `recordRemote` freezes the stored capability array to prevent the caller from mutating the stored entry post-hoc — the negotiator is the boundary that owns the snapshot from then on.
|
||||
- `throwOnEmpty` is a constructor flag rather than a per-call argument so peers from the same negotiator share a single boundary policy. Mixed semantics within one peer would be a code smell.
|
||||
- Peer GC (e.g. forgetting peers older than X minutes) is intentionally not built in — the higher-level peer manager controls peer lifecycle and should call `forgetRemote` from its own `peer:disconnected` handler.
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import type { TransportCapability } from "../types/transport.types.js";
|
||||
|
||||
/**
|
||||
* Stored entry in the negotiator: the capabilities a peer advertised
|
||||
* on its most recent handshake plus the timestamp the negotiator last
|
||||
* heard from them. Used to GC stale peers in long-running processes.
|
||||
*/
|
||||
export interface NegotiatedPeer {
|
||||
readonly peerId: string;
|
||||
readonly capabilities: ReadonlyArray<TransportCapability>;
|
||||
readonly updatedAt: number;
|
||||
}
|
||||
|
||||
export interface CapabilityNegotiatorConfig {
|
||||
readonly local: ReadonlyArray<TransportCapability>;
|
||||
readonly clock?: () => number;
|
||||
/**
|
||||
* If `true`, `commonWith` throws `NO_COMMON_CAPABILITY` when the
|
||||
* intersection is empty. Defaults to `false` so callers can decide
|
||||
* whether emptiness is fatal in their context.
|
||||
*/
|
||||
readonly throwOnEmpty?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages capability bitmaps exchanged at peer handshake time. Local
|
||||
* capabilities are configured once; remote capabilities are recorded
|
||||
* via `recordRemote`. `commonWith` returns the intersection in the
|
||||
* locally-configured order, which is treated as the preference order.
|
||||
*/
|
||||
export class CapabilityNegotiator {
|
||||
private readonly local: ReadonlyArray<TransportCapability>;
|
||||
private readonly remote = new Map<string, NegotiatedPeer>();
|
||||
private readonly clock: () => number;
|
||||
private readonly throwOnEmpty: boolean;
|
||||
|
||||
public constructor(config: CapabilityNegotiatorConfig) {
|
||||
this.local = Object.freeze([...config.local]);
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
this.throwOnEmpty = config.throwOnEmpty ?? false;
|
||||
}
|
||||
|
||||
public localCapabilities(): ReadonlyArray<TransportCapability> {
|
||||
return this.local;
|
||||
}
|
||||
|
||||
public recordRemote(
|
||||
peerId: string,
|
||||
capabilities: ReadonlyArray<TransportCapability>
|
||||
): NegotiatedPeer {
|
||||
const entry: NegotiatedPeer = {
|
||||
peerId,
|
||||
capabilities: Object.freeze([...capabilities]),
|
||||
updatedAt: this.clock(),
|
||||
};
|
||||
this.remote.set(peerId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public hasRemote(peerId: string): boolean {
|
||||
return this.remote.has(peerId);
|
||||
}
|
||||
|
||||
public forgetRemote(peerId: string): void {
|
||||
this.remote.delete(peerId);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.remote.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the intersection of local and remote capabilities, in
|
||||
* local preference order. Optionally throws `NO_COMMON_CAPABILITY`
|
||||
* when the result is empty (depending on constructor config).
|
||||
*/
|
||||
public commonWith(peerId: string): ReadonlyArray<TransportCapability> {
|
||||
const remote = this.remote.get(peerId);
|
||||
if (remote === undefined) {
|
||||
if (this.throwOnEmpty) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.NO_COMMON_CAPABILITY,
|
||||
"negotiator.unknown-peer",
|
||||
{ context: { peerId } }
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const remoteSet = new Set(remote.capabilities);
|
||||
const out: TransportCapability[] = [];
|
||||
for (const cap of this.local) {
|
||||
if (remoteSet.has(cap)) out.push(cap);
|
||||
}
|
||||
if (out.length === 0 && this.throwOnEmpty) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.NO_COMMON_CAPABILITY,
|
||||
"negotiator.no-intersection",
|
||||
{
|
||||
context: {
|
||||
peerId,
|
||||
local: [...this.local],
|
||||
remote: [...remote.capabilities],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TM_CODES, type NetworkTmError } from "../error-codes.js";
|
||||
import { CapabilityNegotiator } from "./capability-negotiator.js";
|
||||
|
||||
describe("NET-24 — CapabilityNegotiator capability exchange", () => {
|
||||
it("computes intersection in local preference order", () => {
|
||||
const n = new CapabilityNegotiator({
|
||||
local: ["webrtc-datachannel", "websocket", "http-fetch"],
|
||||
});
|
||||
n.recordRemote("peer-A", ["http-fetch", "websocket"]);
|
||||
expect(n.commonWith("peer-A")).toEqual(["websocket", "http-fetch"]);
|
||||
});
|
||||
|
||||
it("returns [] for unknown peers when throwOnEmpty is false", () => {
|
||||
const n = new CapabilityNegotiator({
|
||||
local: ["websocket"],
|
||||
});
|
||||
expect(n.commonWith("ghost")).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws NO_COMMON_CAPABILITY for unknown peers when throwOnEmpty", () => {
|
||||
const n = new CapabilityNegotiator({
|
||||
local: ["websocket"],
|
||||
throwOnEmpty: true,
|
||||
});
|
||||
try {
|
||||
n.commonWith("ghost");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.NO_COMMON_CAPABILITY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when intersection is empty under throwOnEmpty", () => {
|
||||
const n = new CapabilityNegotiator({
|
||||
local: ["websocket", "webrtc-datachannel"],
|
||||
throwOnEmpty: true,
|
||||
});
|
||||
n.recordRemote("peer-A", ["http-fetch", "bluetooth-le"]);
|
||||
try {
|
||||
n.commonWith("peer-A");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.NO_COMMON_CAPABILITY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("supports peer GC via forgetRemote and clear", () => {
|
||||
const n = new CapabilityNegotiator({ local: ["websocket"] });
|
||||
n.recordRemote("peer-A", ["websocket"]);
|
||||
n.recordRemote("peer-B", ["websocket"]);
|
||||
expect(n.hasRemote("peer-A")).toBe(true);
|
||||
n.forgetRemote("peer-A");
|
||||
expect(n.hasRemote("peer-A")).toBe(false);
|
||||
n.clear();
|
||||
expect(n.hasRemote("peer-B")).toBe(false);
|
||||
});
|
||||
|
||||
it("freezes local capabilities and stamps updatedAt via clock", () => {
|
||||
const n = new CapabilityNegotiator({
|
||||
local: ["websocket"],
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
const entry = n.recordRemote("peer-A", ["websocket"]);
|
||||
expect(entry.updatedAt).toBe(1_700_000_000_000);
|
||||
expect(Object.isFrozen(entry.capabilities)).toBe(true);
|
||||
expect(Object.isFrozen(n.localCapabilities())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TM_CODES, type NetworkTmError } from "../error-codes.js";
|
||||
import { SessionPersistenceBuffer } from "./session-persistence-buffer.js";
|
||||
|
||||
describe("NET-60 — SessionPersistenceBuffer priority replay & overflow", () => {
|
||||
it("drains in priority-then-FIFO order", () => {
|
||||
const buf = new SessionPersistenceBuffer<string>({
|
||||
clock: () => 0,
|
||||
});
|
||||
buf.enqueue({ id: "a", priority: "BULK", payload: "" });
|
||||
buf.enqueue({ id: "b", priority: "CRITICAL", payload: "" });
|
||||
buf.enqueue({ id: "c", priority: "NORMAL", payload: "" });
|
||||
buf.enqueue({ id: "d", priority: "CRITICAL", payload: "" });
|
||||
const drained = buf.drain();
|
||||
expect(drained.map((m) => m.id)).toEqual(["b", "d", "c", "a"]);
|
||||
expect(buf.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("evicts lowest-priority oldest entry when at capacity", () => {
|
||||
const buf = new SessionPersistenceBuffer<string>({
|
||||
capacity: 3,
|
||||
});
|
||||
buf.enqueue({ id: "a", priority: "BULK", payload: "" });
|
||||
buf.enqueue({ id: "b", priority: "LOW", payload: "" });
|
||||
buf.enqueue({ id: "c", priority: "NORMAL", payload: "" });
|
||||
buf.enqueue({ id: "d", priority: "CRITICAL", payload: "" });
|
||||
const peek = buf.peek().map((m) => m.id);
|
||||
expect(peek).not.toContain("a");
|
||||
expect(peek).toContain("d");
|
||||
});
|
||||
|
||||
it("CRITICAL overflow throws BUFFER_OVERFLOW when nothing evictable", () => {
|
||||
const buf = new SessionPersistenceBuffer<string>({ capacity: 2 });
|
||||
buf.enqueue({ id: "a", priority: "CRITICAL", payload: "" });
|
||||
buf.enqueue({ id: "b", priority: "CRITICAL", payload: "" });
|
||||
try {
|
||||
buf.enqueue({ id: "c", priority: "CRITICAL", payload: "" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.BUFFER_OVERFLOW
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-CRITICAL overflow falls back to evicting any same-tier entry", () => {
|
||||
const buf = new SessionPersistenceBuffer<string>({ capacity: 1 });
|
||||
buf.enqueue({ id: "a", priority: "NORMAL", payload: "" });
|
||||
buf.enqueue({ id: "b", priority: "NORMAL", payload: "" });
|
||||
expect(buf.peek().map((m) => m.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("CRITICAL overflow evicts a non-CRITICAL entry first when one exists", () => {
|
||||
const buf = new SessionPersistenceBuffer<string>({ capacity: 2 });
|
||||
// Two CRITICAL entries fill the buffer first, then we replace one
|
||||
// by enqueuing a new NORMAL — wait, simpler: capacity 2 with one
|
||||
// CRITICAL + one NORMAL, then push a CRITICAL.
|
||||
buf.enqueue({ id: "crit-1", priority: "CRITICAL", payload: "" });
|
||||
buf.enqueue({ id: "norm-1", priority: "NORMAL", payload: "" });
|
||||
buf.enqueue({ id: "crit-2", priority: "CRITICAL", payload: "" });
|
||||
const ids = buf.peek().map((m) => m.id);
|
||||
expect(ids).toContain("crit-1");
|
||||
expect(ids).toContain("crit-2");
|
||||
expect(ids).not.toContain("norm-1");
|
||||
});
|
||||
|
||||
it("find / remove operate by id", () => {
|
||||
const buf = new SessionPersistenceBuffer<string>();
|
||||
buf.enqueue({ id: "a", priority: "NORMAL", payload: "x" });
|
||||
expect(buf.find("a")?.id).toBe("a");
|
||||
expect(buf.find("missing")).toBeUndefined();
|
||||
const removed = buf.remove("a");
|
||||
expect(removed.id).toBe("a");
|
||||
try {
|
||||
buf.remove("a");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.RECOVERY_NOT_BUFFERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("clear empties the buffer", () => {
|
||||
const buf = new SessionPersistenceBuffer<string>();
|
||||
buf.enqueue({ id: "a", priority: "NORMAL", payload: "" });
|
||||
buf.clear();
|
||||
expect(buf.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("non-CRITICAL incoming drops oldest message when only HIGHER-priority entries exist", () => {
|
||||
const buf = new SessionPersistenceBuffer<string>({ capacity: 1 });
|
||||
buf.enqueue({ id: "crit", priority: "CRITICAL", payload: "" });
|
||||
buf.enqueue({ id: "norm", priority: "NORMAL", payload: "" });
|
||||
// CRITICAL was the only entry; the NORMAL incoming triggered the
|
||||
// last-resort shift() since neither lower-priority nor same-tier
|
||||
// entries existed.
|
||||
expect(buf.peek().map((m) => m.id)).toEqual(["norm"]);
|
||||
});
|
||||
|
||||
it("preserves enqueuedAt via injectable clock", () => {
|
||||
let now = 100;
|
||||
const buf = new SessionPersistenceBuffer<string>({
|
||||
clock: () => now,
|
||||
});
|
||||
buf.enqueue({ id: "a", priority: "NORMAL", payload: "" });
|
||||
now = 200;
|
||||
buf.enqueue({ id: "b", priority: "NORMAL", payload: "" });
|
||||
expect(buf.find("a")?.enqueuedAt).toBe(100);
|
||||
expect(buf.find("b")?.enqueuedAt).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
# `SessionPersistenceBuffer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Holds outbound messages while a session is in a temporary-drop state so the recovery manager can replay them in priority order once a new transport is established. Without this buffer, messages produced during the brief window between disconnect and reconnect would be lost — including CRITICAL host-election traffic that the mesh cannot tolerate losing.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority-aware enqueue: messages remember the tier they were sent at, so drains can replay CRITICAL/HIGH ahead of NORMAL/LOW/BULK.
|
||||
- Hard capacity ceiling: when the buffer is full, the oldest lowest-priority entry is evicted to make room. CRITICAL messages never silently drop — `BUFFER_OVERFLOW` is raised when even non-CRITICAL eviction fails.
|
||||
- `drain()` returns the buffered messages in priority-then-FIFO order and clears the buffer atomically.
|
||||
- Injectable clock so deterministic tests can assert priority/order without timer flakiness.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SessionPersistenceBuffer } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const buf = new SessionPersistenceBuffer<string>();
|
||||
buf.enqueue({ id: "m1", priority: "NORMAL", payload: "hello" });
|
||||
buf.enqueue({ id: "m2", priority: "CRITICAL", payload: "election:vote" });
|
||||
|
||||
const ordered = buf.drain();
|
||||
// ordered[0].id === "m2" — CRITICAL ahead of NORMAL
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SessionPersistenceBuffer } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const buf = new SessionPersistenceBuffer<{ kind: string }>({
|
||||
capacity: 3,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
buf.enqueue({ id: "a", priority: "BULK", payload: { kind: "chunk" } });
|
||||
now += 10;
|
||||
buf.enqueue({ id: "b", priority: "LOW", payload: { kind: "metric" } });
|
||||
now += 10;
|
||||
buf.enqueue({ id: "c", priority: "NORMAL", payload: { kind: "state" } });
|
||||
|
||||
// Buffer is full — incoming CRITICAL evicts the lowest-priority oldest entry.
|
||||
buf.enqueue({ id: "d", priority: "CRITICAL", payload: { kind: "vote" } });
|
||||
buf.size(); // 3 — BULK ('a') was evicted, CRITICAL accepted
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SessionPersistenceBuffer<TPayload = unknown>(config?)`
|
||||
- `size(): number`.
|
||||
- `peek(): ReadonlyArray<BufferedMessage<TPayload>>` — non-destructive snapshot.
|
||||
- `enqueue({ id, priority, payload }): BufferedMessage<TPayload>`.
|
||||
- `drain(): ReadonlyArray<BufferedMessage<TPayload>>` — priority-then-FIFO; clears the buffer.
|
||||
- `find(id): BufferedMessage<TPayload> | undefined`.
|
||||
- `remove(id): BufferedMessage<TPayload>` — throws `RECOVERY_NOT_BUFFERED` when the id is missing.
|
||||
- `clear(): void`.
|
||||
- `interface BufferedMessage<TPayload>` — `{ id, priority, payload, enqueuedAt }`.
|
||||
- `interface SessionPersistenceBufferConfig` — `{ capacity?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The eviction policy walks priorities from BULK toward CRITICAL, so the _lowest-priority_ oldest entry leaves first regardless of arrival order. This matches operator intuition: filling up on telemetry should not push out election votes.
|
||||
- `drain` returns a sorted copy of the entries because callers MAY want to filter or re-enqueue some messages without losing the original ordering. Mutating the returned array does not affect future enqueues.
|
||||
- `BUFFER_OVERFLOW` is only raised when the buffer is at capacity AND every existing entry is CRITICAL AND the incoming is also CRITICAL. In every other shape we make room. Tests cover both branches.
|
||||
- Insertion order matters: `find` and `remove` walk the underlying array; for the typical buffer size (≤ 1024) this is well below the threshold where a `Map`-by-id index would pay for itself.
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import type { MessagePriority } from "../types/priority.types.js";
|
||||
|
||||
/**
|
||||
* Buffered message awaiting replay. Carries the original priority so
|
||||
* the recovery manager can replay CRITICAL traffic ahead of NORMAL
|
||||
* and BULK on reconnection.
|
||||
*/
|
||||
export interface BufferedMessage<TPayload = unknown> {
|
||||
readonly id: string;
|
||||
readonly priority: MessagePriority;
|
||||
readonly payload: TPayload;
|
||||
readonly enqueuedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionPersistenceBufferConfig {
|
||||
/** Hard cap on buffered messages — past this, oldest LOW/BULK drop first. */
|
||||
readonly capacity?: number;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
const PRIORITY_ORDER: ReadonlyArray<MessagePriority> = [
|
||||
"CRITICAL",
|
||||
"HIGH",
|
||||
"NORMAL",
|
||||
"LOW",
|
||||
"BULK",
|
||||
];
|
||||
|
||||
/**
|
||||
* Buffers outbound messages while a session is disconnected, preserving
|
||||
* their priority so the recovery manager can replay them in
|
||||
* priority-then-FIFO order. Hits a hard capacity ceiling and refuses
|
||||
* additional CRITICAL messages with `BUFFER_OVERFLOW` rather than
|
||||
* silently dropping them.
|
||||
*/
|
||||
export class SessionPersistenceBuffer<TPayload = unknown> {
|
||||
private readonly capacity: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly entries: BufferedMessage<TPayload>[] = [];
|
||||
|
||||
public constructor(config: SessionPersistenceBufferConfig = {}) {
|
||||
this.capacity = config.capacity ?? 1024;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
public peek(): ReadonlyArray<BufferedMessage<TPayload>> {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
public enqueue(input: {
|
||||
id: string;
|
||||
priority: MessagePriority;
|
||||
payload: TPayload;
|
||||
}): BufferedMessage<TPayload> {
|
||||
if (this.entries.length >= this.capacity) {
|
||||
this.makeRoom(input.priority);
|
||||
}
|
||||
const message: BufferedMessage<TPayload> = {
|
||||
id: input.id,
|
||||
priority: input.priority,
|
||||
payload: input.payload,
|
||||
enqueuedAt: this.clock(),
|
||||
};
|
||||
this.entries.push(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain in priority-then-FIFO order. The buffer is emptied as a
|
||||
* side effect — callers are expected to re-enqueue any messages
|
||||
* whose redelivery fails.
|
||||
*/
|
||||
public drain(): ReadonlyArray<BufferedMessage<TPayload>> {
|
||||
const ordered = [...this.entries].sort((a, b) => {
|
||||
const pa = PRIORITY_ORDER.indexOf(a.priority);
|
||||
const pb = PRIORITY_ORDER.indexOf(b.priority);
|
||||
if (pa !== pb) return pa - pb;
|
||||
return a.enqueuedAt - b.enqueuedAt;
|
||||
});
|
||||
this.entries.length = 0;
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Find a message by id without removing it. */
|
||||
public find(id: string): BufferedMessage<TPayload> | undefined {
|
||||
return this.entries.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
/** Remove a message by id. */
|
||||
public remove(id: string): BufferedMessage<TPayload> {
|
||||
const index = this.entries.findIndex((m) => m.id === id);
|
||||
if (index < 0) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.RECOVERY_NOT_BUFFERED,
|
||||
"buffer.missing",
|
||||
{ context: { messageId: id } }
|
||||
);
|
||||
}
|
||||
// findIndex returned a valid index, so splice yields an entry.
|
||||
const [removed] = this.entries.splice(index, 1) as [
|
||||
BufferedMessage<TPayload>,
|
||||
];
|
||||
return removed;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.entries.length = 0;
|
||||
}
|
||||
|
||||
private makeRoom(incoming: MessagePriority): void {
|
||||
// Walk priorities lowest → highest, evicting the first entry
|
||||
// whose tier is strictly lower than the incoming.
|
||||
const incomingIdx = PRIORITY_ORDER.indexOf(incoming);
|
||||
for (let i = PRIORITY_ORDER.length - 1; i > incomingIdx; i -= 1) {
|
||||
const tier = PRIORITY_ORDER[i] as MessagePriority;
|
||||
const idx = this.entries.findIndex((m) => m.priority === tier);
|
||||
if (idx >= 0) {
|
||||
this.entries.splice(idx, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (incoming === "CRITICAL") {
|
||||
// No lower-priority entry exists and incoming is also CRITICAL —
|
||||
// refuse rather than dropping a peer-critical message.
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.BUFFER_OVERFLOW,
|
||||
"buffer.full-critical",
|
||||
{ context: { capacity: this.capacity } }
|
||||
);
|
||||
}
|
||||
// Drop the oldest same-tier entry.
|
||||
const sameIdx = this.entries.findIndex((m) => m.priority === incoming);
|
||||
if (sameIdx >= 0) {
|
||||
this.entries.splice(sameIdx, 1);
|
||||
return;
|
||||
}
|
||||
// Last resort: every existing entry is HIGHER priority — drop the
|
||||
// oldest message regardless so the incoming non-CRITICAL still lands.
|
||||
this.entries.shift();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TransportManagerPlugin } from "./transport-manager-plugin.js";
|
||||
|
||||
const newNode = () => {
|
||||
const bus = new NetworkBus();
|
||||
return new NetworkNode({ id: "node-1", networkBus: bus });
|
||||
};
|
||||
|
||||
describe("TransportManagerPlugin: NetworkPlugin lifecycle", () => {
|
||||
it("constructs with every subsystem ready", () => {
|
||||
const plugin = new TransportManagerPlugin({
|
||||
local: ["webrtc-datachannel", "websocket"],
|
||||
});
|
||||
expect(plugin.name).toBe("transport-manager");
|
||||
expect(plugin.pool).toBeDefined();
|
||||
expect(plugin.negotiator).toBeDefined();
|
||||
expect(plugin.priority).toBeDefined();
|
||||
expect(plugin.selector).toBeDefined();
|
||||
expect(plugin.balancer).toBeDefined();
|
||||
expect(plugin.analyzer).toBeDefined();
|
||||
expect(plugin.preemptive).toBeDefined();
|
||||
expect(plugin.buffer).toBeDefined();
|
||||
expect(plugin.recovery).toBeDefined();
|
||||
expect(plugin.continuity).toBeDefined();
|
||||
expect(plugin.battery).toBeDefined();
|
||||
expect(plugin.bandwidth).toBeDefined();
|
||||
});
|
||||
|
||||
it("install wires the health monitor to the node bus", async () => {
|
||||
const plugin = new TransportManagerPlugin({
|
||||
local: ["websocket"],
|
||||
});
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.health).not.toBeNull();
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("start / stop set isStarted accordingly", async () => {
|
||||
const plugin = new TransportManagerPlugin({ local: ["websocket"] });
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.isStarted()).toBe(true);
|
||||
await node.stop();
|
||||
expect(plugin.isStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it("dispose clears every subsystem", async () => {
|
||||
const plugin = new TransportManagerPlugin({
|
||||
local: ["websocket"],
|
||||
recovery: {
|
||||
baseMs: 100,
|
||||
maxMs: 1_000,
|
||||
factor: 2,
|
||||
jitter: 0,
|
||||
rng: () => 0,
|
||||
},
|
||||
});
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
plugin.pool.register({
|
||||
id: "ws",
|
||||
capability: "websocket",
|
||||
connected: true,
|
||||
});
|
||||
plugin.negotiator.recordRemote("p", ["websocket"]);
|
||||
plugin.recovery.nextDelay();
|
||||
plugin.balancer.next(["websocket"]);
|
||||
plugin.analyzer.record("ws", { latency: 1, loss: 0, bandwidthBps: 1 });
|
||||
plugin.buffer.enqueue({ id: "x", priority: "NORMAL", payload: 0 });
|
||||
plugin.priority.shouldDeliver("CRITICAL", "x");
|
||||
await node.stop();
|
||||
plugin.dispose();
|
||||
expect(plugin.pool.size()).toBe(0);
|
||||
expect(plugin.negotiator.hasRemote("p")).toBe(false);
|
||||
expect(plugin.recovery.attempts()).toBe(0);
|
||||
expect(plugin.buffer.size()).toBe(0);
|
||||
expect(plugin.analyzer.window("ws").latency.length).toBe(0);
|
||||
expect(plugin.health).toBeNull();
|
||||
// Priority router dedup state is cleared
|
||||
expect(plugin.priority.shouldDeliver("CRITICAL", "x")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
# `TransportManagerPlugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single `NetworkPlugin` that wires every transport-manager subsystem into a `NetworkNode` lifecycle. Owns the `TransportPool` (truth), `CapabilityNegotiator` (peer agreement), `PriorityRouter` + `TransportSelector` + `RoundRobinBalancer` (selection), `TransportHealthMonitor` (observability), `QualityTrendAnalyzer` + `PreemptiveSelector` (early-warning swap), `SessionPersistenceBuffer` + `ConnectionRecoveryManager` + `SessionContinuityGuard` (drop survival), and `BatteryAwareScheduler` + `BandwidthConservator` (resource conservation). Each is exposed as a public field so consumers can inspect or extend without subclassing.
|
||||
|
||||
## Features
|
||||
|
||||
- Single registration — drop into `node.use(plugin)` and every subsystem is wired and reachable.
|
||||
- Public fields per subsystem so consumers can subscribe, override, or instrument selectively.
|
||||
- Idempotent `start` / `stop` / `dispose` lifecycle.
|
||||
- Health monitor receives the node's bus on `install(node)` so `transport:health-change` events surface to subscribers without manual wiring.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { TransportManagerPlugin } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const node = new NetworkNode({ id: "node-1", networkBus: bus });
|
||||
const tm = new TransportManagerPlugin({
|
||||
local: ["webrtc-datachannel", "websocket"],
|
||||
});
|
||||
node.use(tm);
|
||||
await node.start();
|
||||
|
||||
tm.pool.register({ id: "ws-1", capability: "websocket", connected: true });
|
||||
const result = tm.selector.select({
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["websocket"],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { TransportManagerPlugin } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const tm = new TransportManagerPlugin({
|
||||
local: ["webrtc-datachannel", "websocket", "http-fetch"],
|
||||
recovery: { baseMs: 200, maxMs: 10_000, factor: 1.7, jitter: 0.4 },
|
||||
health: { pauseBelow: 25, resumeAtOrAbove: 65 },
|
||||
preemptive: { overlapMs: 1_500, minScoreGap: 20 },
|
||||
battery: { lowBatteryAt: 0.15 },
|
||||
bandwidth: { batchIntervalMs: 150 },
|
||||
});
|
||||
node.use(tm);
|
||||
await node.start();
|
||||
|
||||
// Mobile peer: tighten power policy when battery drops
|
||||
tm.battery.update({ onBattery: true, level: 0.10, charging: false });
|
||||
const keepalive = tm.battery.keepaliveMs(); // aggressive backoff
|
||||
|
||||
// On disconnect: enter buffering, then replay on reconnect
|
||||
tm.continuity.enterBuffering();
|
||||
tm.continuity.bufferOutbound({ id: "x", priority: "CRITICAL", payload: "vote" });
|
||||
|
||||
const replay = tm.continuity.enterRecovering();
|
||||
tm.continuity.markConnected();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransportManagerPlugin implements NetworkPlugin`
|
||||
- Public fields: `pool`, `negotiator`, `priority`, `selector`, `balancer`, `analyzer`, `preemptive`, `buffer`, `recovery`, `continuity`, `battery`, `bandwidth`, `health` (set on `install`).
|
||||
- `install(node: NetworkNode): void` — wires the health monitor to `node.networkBus`.
|
||||
- `start(): void` / `stop(): void` — idempotent lifecycle.
|
||||
- `dispose(): void` — clears every subsystem (pool, negotiator, priority, balancer, analyzer, buffer, recovery) and detaches the health monitor.
|
||||
- `isStarted(): boolean`.
|
||||
- `interface TransportManagerPluginConfig` — `{ local, recovery?, health?, buffer?, priority?, preemptive?, battery?, bandwidth? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The plugin holds _references_ to subsystems rather than re-exporting their methods so the public surface stays explicit. Callers that want a "facade" can write a thin wrapper around the fields — keeping the plugin minimal makes refactors cheaper.
|
||||
- `health` is the only field that depends on the node's bus, so it is initialised in `install(node)` rather than the constructor. The other subsystems are constructed eagerly so unit tests can build the plugin without a node.
|
||||
- `dispose` does NOT discard the immutable subsystems (those are still referenced via the public fields) but it DOES reset their internal state. Callers that want a clean re-install should construct a new plugin instance — the same trade-off the failover plugin uses.
|
||||
- The lifecycle flag `started` is purely informational. Every subsystem is fully usable without the plugin being started — `start` exists to satisfy the `NetworkPlugin` contract and to give consumers a single hook for "OK, now you may begin processing".
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
|
||||
import { QualityTrendAnalyzer } from "../analyzer/quality-trend-analyzer.js";
|
||||
import { RoundRobinBalancer } from "../balancer/round-robin-balancer.js";
|
||||
import { TransportHealthMonitor } from "../health/transport-health-monitor.js";
|
||||
import { CapabilityNegotiator } from "../negotiator/capability-negotiator.js";
|
||||
import { SessionPersistenceBuffer } from "../persistence/session-persistence-buffer.js";
|
||||
import { TransportPool } from "../pool/transport-pool.js";
|
||||
import { PriorityRouter } from "../priority/priority-router.js";
|
||||
import { ConnectionRecoveryManager } from "../recovery/connection-recovery-manager.js";
|
||||
import { SessionContinuityGuard } from "../recovery/session-continuity-guard.js";
|
||||
import { BandwidthConservator } from "../scheduler/bandwidth-conservator.js";
|
||||
import { BatteryAwareScheduler } from "../scheduler/battery-aware-scheduler.js";
|
||||
import { PreemptiveSelector } from "../selector/preemptive-selector.js";
|
||||
import { TransportSelector } from "../selector/transport-selector.js";
|
||||
import type { TransportCapability } from "../types/transport.types.js";
|
||||
|
||||
export interface TransportManagerPluginConfig {
|
||||
readonly local: ReadonlyArray<TransportCapability>;
|
||||
readonly recovery?: ConstructorParameters<
|
||||
typeof ConnectionRecoveryManager
|
||||
>[0];
|
||||
readonly health?: ConstructorParameters<typeof TransportHealthMonitor>[2];
|
||||
readonly buffer?: ConstructorParameters<typeof SessionPersistenceBuffer>[0];
|
||||
readonly priority?: ConstructorParameters<typeof PriorityRouter>[0];
|
||||
readonly preemptive?: ConstructorParameters<typeof PreemptiveSelector>[2];
|
||||
readonly battery?: ConstructorParameters<typeof BatteryAwareScheduler>[1];
|
||||
readonly bandwidth?: ConstructorParameters<typeof BandwidthConservator>[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* `NetworkPlugin` that bundles every transport-manager subsystem into
|
||||
* a single composable unit. Each subsystem is exposed as a public
|
||||
* field so callers can inspect, observe, or extend behaviour without
|
||||
* going through the plugin abstraction.
|
||||
*/
|
||||
export class TransportManagerPlugin implements NetworkPlugin {
|
||||
public readonly name = "transport-manager";
|
||||
public readonly pool: TransportPool;
|
||||
public readonly negotiator: CapabilityNegotiator;
|
||||
public readonly priority: PriorityRouter;
|
||||
public readonly selector: TransportSelector;
|
||||
public readonly balancer: RoundRobinBalancer;
|
||||
public readonly analyzer: QualityTrendAnalyzer;
|
||||
public readonly preemptive: PreemptiveSelector;
|
||||
public readonly buffer: SessionPersistenceBuffer;
|
||||
public readonly recovery: ConnectionRecoveryManager;
|
||||
public readonly continuity: SessionContinuityGuard;
|
||||
public readonly battery: BatteryAwareScheduler;
|
||||
public readonly bandwidth: BandwidthConservator;
|
||||
public health: TransportHealthMonitor | null = null;
|
||||
private readonly config: TransportManagerPluginConfig;
|
||||
private started = false;
|
||||
|
||||
public constructor(config: TransportManagerPluginConfig) {
|
||||
this.config = config;
|
||||
this.pool = new TransportPool();
|
||||
this.negotiator = new CapabilityNegotiator({ local: config.local });
|
||||
this.priority = new PriorityRouter(config.priority);
|
||||
this.selector = new TransportSelector(this.pool, this.priority);
|
||||
this.balancer = new RoundRobinBalancer(this.pool);
|
||||
this.analyzer = new QualityTrendAnalyzer();
|
||||
this.preemptive = new PreemptiveSelector(
|
||||
this.pool,
|
||||
this.analyzer,
|
||||
config.preemptive
|
||||
);
|
||||
this.buffer = new SessionPersistenceBuffer(config.buffer);
|
||||
this.recovery = new ConnectionRecoveryManager(config.recovery);
|
||||
this.continuity = new SessionContinuityGuard(this.buffer);
|
||||
this.battery = new BatteryAwareScheduler(this.pool, config.battery);
|
||||
this.bandwidth = new BandwidthConservator(config.bandwidth);
|
||||
}
|
||||
|
||||
public install(node: NetworkNode): void {
|
||||
const bus = node.networkBus as unknown as ConstructorParameters<
|
||||
typeof TransportHealthMonitor
|
||||
>[1];
|
||||
this.health = new TransportHealthMonitor(
|
||||
this.pool,
|
||||
bus,
|
||||
this.config.health
|
||||
);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.pool.clear();
|
||||
this.negotiator.clear();
|
||||
this.priority.reset();
|
||||
this.balancer.reset();
|
||||
this.analyzer.reset();
|
||||
this.buffer.clear();
|
||||
this.recovery.reset();
|
||||
this.health = null;
|
||||
}
|
||||
|
||||
public isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import {
|
||||
DEFAULT_HEALTH,
|
||||
scoreFromSample,
|
||||
TransportPool,
|
||||
} from "./transport-pool.js";
|
||||
|
||||
const transport = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
capability: "websocket" as const,
|
||||
connected: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("NET-23 — TransportPool registration & health tracking", () => {
|
||||
it("registers a new transport with neutral health", () => {
|
||||
const pool = new TransportPool();
|
||||
const entry = pool.register(transport("ws-1"));
|
||||
expect(entry.health).toEqual(DEFAULT_HEALTH);
|
||||
expect(pool.size()).toBe(1);
|
||||
expect(pool.has("ws-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate ids with TRANSPORT_ALREADY_REGISTERED", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(transport("ws-1"));
|
||||
try {
|
||||
pool.register(transport("ws-1"));
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NetworkTmError);
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.TRANSPORT_ALREADY_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("supports register-unregister cycles", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(transport("ws-1"));
|
||||
pool.unregister("ws-1");
|
||||
expect(pool.size()).toBe(0);
|
||||
expect(pool.has("ws-1")).toBe(false);
|
||||
// re-register OK
|
||||
pool.register(transport("ws-1"));
|
||||
expect(pool.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("unregistering a missing id throws TRANSPORT_NOT_REGISTERED", () => {
|
||||
const pool = new TransportPool();
|
||||
try {
|
||||
pool.unregister("missing");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.TRANSPORT_NOT_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("get / getHealth throw when missing", () => {
|
||||
const pool = new TransportPool();
|
||||
expect(() => pool.get("nope")).toThrow(NetworkTmError);
|
||||
expect(() => pool.getHealth("nope")).toThrow(NetworkTmError);
|
||||
});
|
||||
|
||||
it("setHealth replaces the entry without losing the transport reference", () => {
|
||||
const pool = new TransportPool();
|
||||
const t = transport("ws-1");
|
||||
pool.register(t);
|
||||
const updated = pool.setHealth("ws-1", {
|
||||
rttMs: 100,
|
||||
lossRate: 0.1,
|
||||
stability: 0.5,
|
||||
score: 50,
|
||||
paused: false,
|
||||
});
|
||||
expect(updated.transport).toBe(t);
|
||||
expect(pool.getHealth("ws-1").score).toBe(50);
|
||||
});
|
||||
|
||||
it("listHealthy filters paused transports", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(transport("ws-1"));
|
||||
pool.register(transport("ws-2"));
|
||||
pool.setHealth("ws-1", {
|
||||
...DEFAULT_HEALTH,
|
||||
paused: true,
|
||||
});
|
||||
const healthy = pool.listHealthy();
|
||||
expect(healthy.map((e) => e.transport.id)).toEqual(["ws-2"]);
|
||||
});
|
||||
|
||||
it("list returns every entry", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(transport("ws-1"));
|
||||
pool.register(transport("ws-2"));
|
||||
expect(pool.list().map((e) => e.transport.id)).toEqual([
|
||||
"ws-1",
|
||||
"ws-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clear empties the pool", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(transport("ws-1"));
|
||||
pool.clear();
|
||||
expect(pool.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("scoreFromSample is monotonic in penalties and clamped", () => {
|
||||
// healthy
|
||||
expect(scoreFromSample({ rttMs: 0, lossRate: 0, stability: 1 })).toBe(
|
||||
100
|
||||
);
|
||||
// huge penalties → clamped to 0
|
||||
expect(
|
||||
scoreFromSample({ rttMs: 10_000, lossRate: 1, stability: 0 })
|
||||
).toBe(0);
|
||||
// ordering
|
||||
const better = scoreFromSample({
|
||||
rttMs: 60,
|
||||
lossRate: 0,
|
||||
stability: 1,
|
||||
});
|
||||
const worse = scoreFromSample({
|
||||
rttMs: 60,
|
||||
lossRate: 0.05,
|
||||
stability: 0.5,
|
||||
});
|
||||
expect(better).toBeGreaterThan(worse);
|
||||
});
|
||||
|
||||
it("scoreFromSample clamps stability and loss when out of range", () => {
|
||||
// Negative loss / >1 stability are clamped silently
|
||||
expect(scoreFromSample({ rttMs: 0, lossRate: -1, stability: 5 })).toBe(
|
||||
100
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
# `TransportPool`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every transport the manager knows about. Each registered transport gets a health snapshot tracked alongside its structural identity, so downstream selectors, balancers, and the health monitor can ask the pool for both the live `TransportLike` reference and the most-recent score in a single lookup.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent-friendly registration: duplicate ids raise `TRANSPORT_ALREADY_REGISTERED` so callers decide between update vs recreate.
|
||||
- Health-aware listing: `listHealthy()` returns only the entries that the monitor has not paused.
|
||||
- Direct lookup APIs (`get`, `getHealth`, `has`) so consumers do not need to defensively re-check `Map` semantics.
|
||||
- Pure score function `scoreFromSample({ rttMs, lossRate, stability })` returning a clamped `[0, 100]` value used by the health monitor.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TransportPool } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "ws-1", capability: "websocket", connected: true });
|
||||
const score = pool.getHealth("ws-1").score; // 100 by default
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
scoreFromSample,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc-1", capability: "webrtc-datachannel", connected: true });
|
||||
|
||||
// After a quality probe…
|
||||
const sample = { rttMs: 220, lossRate: 0.04, stability: 0.92 };
|
||||
const score = scoreFromSample(sample);
|
||||
pool.setHealth("wrtc-1", {
|
||||
rttMs: sample.rttMs,
|
||||
lossRate: sample.lossRate,
|
||||
stability: sample.stability,
|
||||
score,
|
||||
paused: score < 30,
|
||||
});
|
||||
|
||||
for (const entry of pool.listHealthy()) {
|
||||
console.log(entry.transport.id, entry.health.score);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransportPool` — in-memory map of `id → TransportEntry`.
|
||||
- `register(transport: TransportLike): TransportEntry` — throws `TRANSPORT_ALREADY_REGISTERED` if the id is taken.
|
||||
- `unregister(transportId: string): void` — throws `TRANSPORT_NOT_REGISTERED` if the id is not present.
|
||||
- `has(transportId: string): boolean`.
|
||||
- `size(): number`.
|
||||
- `get(transportId: string): TransportEntry` — throws `TRANSPORT_NOT_REGISTERED` if missing.
|
||||
- `list(): ReadonlyArray<TransportEntry>` and `listHealthy(): ReadonlyArray<TransportEntry>`.
|
||||
- `getHealth(transportId: string): TransportHealth`.
|
||||
- `setHealth(transportId: string, health: TransportHealth): TransportEntry`.
|
||||
- `clear(): void`.
|
||||
- `DEFAULT_HEALTH` — frozen neutral baseline applied at registration.
|
||||
- `scoreFromSample(input): number` — pure score function; clamps to `[0, 100]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The pool stores `TransportEntry` as immutable snapshots; `setHealth` replaces the entry rather than mutating it, so consumers can hold a reference safely while the monitor updates the score.
|
||||
- `scoreFromSample` is intentionally simple — half-point per RTT-ms above a 50 ms floor, 50 points for full loss, 25 points for full instability — because real-world transport selection is dominated by the relative ordering, not the absolute number. Tests pin the formula at boundary values.
|
||||
- Errors are thrown rather than returned as `Result<T>` because the pool sits inside a single trust boundary (the manager process) and registration mistakes indicate caller bugs that should fail fast.
|
||||
@@ -0,0 +1,137 @@
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import type {
|
||||
TransportEntry,
|
||||
TransportHealth,
|
||||
TransportLike,
|
||||
} from "../types/transport.types.js";
|
||||
|
||||
/**
|
||||
* Default neutral health applied to a freshly registered transport.
|
||||
*/
|
||||
export const DEFAULT_HEALTH: TransportHealth = Object.freeze({
|
||||
rttMs: 0,
|
||||
lossRate: 0,
|
||||
stability: 1,
|
||||
score: 100,
|
||||
paused: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Pool of registered transports keyed by `transport.id`. Multiple
|
||||
* transports MAY coexist for the same logical peer — selection happens
|
||||
* downstream in `TransportSelector`. The pool is the single source of
|
||||
* truth for which transports the manager knows about.
|
||||
*/
|
||||
export class TransportPool {
|
||||
private readonly entries = new Map<string, TransportEntry>();
|
||||
|
||||
/**
|
||||
* Register a transport. Throws `TRANSPORT_ALREADY_REGISTERED` if the
|
||||
* id is already present — registration is the boundary where the
|
||||
* caller MUST decide whether to update existing health or recreate.
|
||||
*/
|
||||
public register(transport: TransportLike): TransportEntry {
|
||||
if (this.entries.has(transport.id)) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.TRANSPORT_ALREADY_REGISTERED,
|
||||
"pool.duplicate",
|
||||
{ context: { transportId: transport.id } }
|
||||
);
|
||||
}
|
||||
const entry: TransportEntry = {
|
||||
transport,
|
||||
health: DEFAULT_HEALTH,
|
||||
};
|
||||
this.entries.set(transport.id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public unregister(transportId: string): void {
|
||||
if (!this.entries.delete(transportId)) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.TRANSPORT_NOT_REGISTERED,
|
||||
"pool.missing",
|
||||
{ context: { transportId } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public has(transportId: string): boolean {
|
||||
return this.entries.has(transportId);
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
public get(transportId: string): TransportEntry {
|
||||
const entry = this.entries.get(transportId);
|
||||
if (entry === undefined) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.TRANSPORT_NOT_REGISTERED,
|
||||
"pool.missing",
|
||||
{ context: { transportId } }
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public list(): ReadonlyArray<TransportEntry> {
|
||||
return Array.from(this.entries.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* All entries whose health is not paused — convenience for selectors
|
||||
* and balancers that only care about candidates currently in
|
||||
* service.
|
||||
*/
|
||||
public listHealthy(): ReadonlyArray<TransportEntry> {
|
||||
const out: TransportEntry[] = [];
|
||||
for (const entry of this.entries.values()) {
|
||||
if (!entry.health.paused) out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public getHealth(transportId: string): TransportHealth {
|
||||
return this.get(transportId).health;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the cached health snapshot for a transport. Returns the
|
||||
* stored entry. Used by the health monitor after every sample.
|
||||
*/
|
||||
public setHealth(
|
||||
transportId: string,
|
||||
health: TransportHealth
|
||||
): TransportEntry {
|
||||
const entry = this.get(transportId);
|
||||
const next: TransportEntry = { transport: entry.transport, health };
|
||||
this.entries.set(transportId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a 0–100 transport health score from a sample tuple. The
|
||||
* formula linearly penalises RTT past 50 ms, packet loss past 0 %, and
|
||||
* instability past 0 %. Callers do NOT need to clamp — the result is
|
||||
* already clamped to the closed interval `[0, 100]`.
|
||||
*/
|
||||
export function scoreFromSample(input: {
|
||||
rttMs: number;
|
||||
lossRate: number;
|
||||
stability: number;
|
||||
}): number {
|
||||
const rttPenalty = Math.min(50, Math.max(0, input.rttMs - 50)) * 0.5;
|
||||
const lossPenalty = Math.min(1, Math.max(0, input.lossRate)) * 50;
|
||||
const instabilityPenalty =
|
||||
(1 - Math.min(1, Math.max(0, input.stability))) * 25;
|
||||
// Sum of max penalties is exactly 100 (25 RTT + 50 loss + 25 stability),
|
||||
// so `raw` is always in `[0, 100]` once the per-component clamps run.
|
||||
return 100 - rttPenalty - lossPenalty - instabilityPenalty;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# `PriorityRouter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Classifies outbound traffic into the five tiers (`CRITICAL`, `HIGH`, `NORMAL`, `LOW`, `BULK`) and answers two operational questions per message: how many transports the selector should fan out to, and whether duplicate arrivals on the receiver side should be suppressed. The router holds the policy table — the selector and balancer only honour what it returns.
|
||||
|
||||
## Features
|
||||
|
||||
- Default config table that mirrors the transport-priority matrix in the proposal (CRITICAL → 2-of-3 redundant, all other tiers single-transport).
|
||||
- Per-tier overrides via the `config` constructor argument.
|
||||
- Bounded dedup tracker (`maxSeenPerTier`, default 4096) so the receiver does not leak memory under sustained CRITICAL traffic.
|
||||
- `isRedundant(priority)` predicate so callers can gate redundant-send code paths without reaching into the config struct.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PriorityRouter } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const router = new PriorityRouter();
|
||||
router.isRedundant("CRITICAL"); // true
|
||||
router.isRedundant("NORMAL"); // false
|
||||
const cfg = router.configFor("HIGH"); // { minTransports: 1, maxTransports: 1, dedup: false }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PriorityRouter } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const router = new PriorityRouter(
|
||||
{
|
||||
// Allow CRITICAL fan-out to 4 transports in this deployment
|
||||
CRITICAL: { minTransports: 2, maxTransports: 4, dedup: true },
|
||||
// Treat BULK as best-effort: skip dedup, single transport
|
||||
BULK: { minTransports: 1, maxTransports: 1, dedup: false },
|
||||
},
|
||||
{ maxSeenPerTier: 1024 }
|
||||
);
|
||||
|
||||
// Receiver-side dedup
|
||||
const id = "msg-7";
|
||||
router.shouldDeliver("CRITICAL", id); // true (first arrival)
|
||||
router.shouldDeliver("CRITICAL", id); // false (duplicate from redundant transport)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PriorityRouter`
|
||||
- `constructor(config?: Partial<Record<MessagePriority, PriorityConfig>>, options?: { maxSeenPerTier?: number })`.
|
||||
- `configFor(priority): PriorityConfig` — throws `PRIORITY_UNKNOWN` for non-tier values (defends against caller bugs that pass arbitrary strings).
|
||||
- `shouldDeliver(priority, messageId): boolean` — returns `true` for non-dedup tiers; for dedup tiers returns `false` on duplicate ids and trims the oldest seen-id once the per-tier cap is reached.
|
||||
- `isRedundant(priority): boolean` — `true` when `minTransports > 1`.
|
||||
- `reset(): void` — drops every dedup bucket.
|
||||
- `DEFAULT_PRIORITY_CONFIG` — frozen baseline policy table.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dedup tracker uses `Set` for O(1) lookup and relies on JavaScript's insertion-order iteration to evict the oldest id when the cap is hit. A min-heap / LRU would scale better past ~10k ids but is unnecessary for the per-tier defaults.
|
||||
- Dedup is per-tier rather than global because the same message id MAY appear on different tiers (e.g. retried as CRITICAL after originally sending as NORMAL) and the receiver should evaluate each independently.
|
||||
- `configFor` throws on unknown tiers rather than returning `undefined` because the rest of the manager treats `MessagePriority` as a closed union — receiving an arbitrary string here means a programming error elsewhere that should fail loudly.
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import type {
|
||||
MessagePriority,
|
||||
PriorityConfig,
|
||||
} from "../types/priority.types.js";
|
||||
|
||||
/**
|
||||
* Default per-tier configuration. Mirrors the priority matrix in the
|
||||
* `network-plugin-transport-manager` proposal:
|
||||
* - CRITICAL: send on at least 2 transports (redundant), dedup
|
||||
* - HIGH: fastest healthy
|
||||
* - NORMAL: round-robin across healthy
|
||||
* - LOW: any available, batch-friendly
|
||||
* - BULK: backpressure-aware, single highest-bandwidth path
|
||||
*/
|
||||
export const DEFAULT_PRIORITY_CONFIG: Readonly<
|
||||
Record<MessagePriority, PriorityConfig>
|
||||
> = Object.freeze({
|
||||
CRITICAL: { minTransports: 2, maxTransports: 3, dedup: true },
|
||||
HIGH: { minTransports: 1, maxTransports: 1, dedup: false },
|
||||
NORMAL: { minTransports: 1, maxTransports: 1, dedup: false },
|
||||
LOW: { minTransports: 1, maxTransports: 1, dedup: false },
|
||||
BULK: { minTransports: 1, maxTransports: 1, dedup: false },
|
||||
});
|
||||
|
||||
const ALL_PRIORITIES: ReadonlyArray<MessagePriority> = Object.freeze([
|
||||
"CRITICAL",
|
||||
"HIGH",
|
||||
"NORMAL",
|
||||
"LOW",
|
||||
"BULK",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Apply the 5-tier policy table to an inbound `(priority, message)` pair
|
||||
* and emit per-tier behaviour. The router does NOT pick transports — it
|
||||
* answers *how many* transports the selector should pick and whether
|
||||
* the dispatcher should track ids for dedup.
|
||||
*/
|
||||
export class PriorityRouter {
|
||||
private readonly config: Readonly<Record<MessagePriority, PriorityConfig>>;
|
||||
private readonly seenIds = new Map<MessagePriority, Set<string>>();
|
||||
private readonly maxSeenPerTier: number;
|
||||
|
||||
public constructor(
|
||||
config: Partial<Record<MessagePriority, PriorityConfig>> = {},
|
||||
options: { maxSeenPerTier?: number } = {}
|
||||
) {
|
||||
const merged: Record<MessagePriority, PriorityConfig> = {
|
||||
...DEFAULT_PRIORITY_CONFIG,
|
||||
};
|
||||
for (const tier of ALL_PRIORITIES) {
|
||||
const override = config[tier];
|
||||
if (override !== undefined) merged[tier] = override;
|
||||
}
|
||||
this.config = Object.freeze(merged);
|
||||
this.maxSeenPerTier = options.maxSeenPerTier ?? 4096;
|
||||
}
|
||||
|
||||
public configFor(priority: MessagePriority): PriorityConfig {
|
||||
const cfg = this.config[priority];
|
||||
if (cfg === undefined) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.PRIORITY_UNKNOWN,
|
||||
"router.unknown-priority",
|
||||
{ context: { priority } }
|
||||
);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should this `messageId` be delivered? Tracks tier-scoped dedup
|
||||
* sets so duplicate arrivals on a redundant tier (CRITICAL) are
|
||||
* suppressed. Always returns true for tiers that opt out of dedup.
|
||||
*/
|
||||
public shouldDeliver(
|
||||
priority: MessagePriority,
|
||||
messageId: string
|
||||
): boolean {
|
||||
const cfg = this.configFor(priority);
|
||||
if (!cfg.dedup) return true;
|
||||
let bucket = this.seenIds.get(priority);
|
||||
if (bucket === undefined) {
|
||||
bucket = new Set();
|
||||
this.seenIds.set(priority, bucket);
|
||||
}
|
||||
if (bucket.has(messageId)) return false;
|
||||
bucket.add(messageId);
|
||||
if (bucket.size > this.maxSeenPerTier) {
|
||||
// Drop the oldest entry — Set preserves insertion order, so
|
||||
// the iterator yields the first key first. The bucket is
|
||||
// non-empty here (we just inserted), so `value` is defined.
|
||||
const first = bucket.values().next().value as string;
|
||||
bucket.delete(first);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when the priority requires fan-out across multiple transports. */
|
||||
public isRedundant(priority: MessagePriority): boolean {
|
||||
return this.configFor(priority).minTransports > 1;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.seenIds.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TM_CODES, type NetworkTmError } from "../error-codes.js";
|
||||
import { DEFAULT_PRIORITY_CONFIG, PriorityRouter } from "./priority-router.js";
|
||||
|
||||
describe("NET-25 — PriorityRouter 5-tier policy & dedup", () => {
|
||||
it("exposes all five default tiers", () => {
|
||||
for (const tier of [
|
||||
"CRITICAL",
|
||||
"HIGH",
|
||||
"NORMAL",
|
||||
"LOW",
|
||||
"BULK",
|
||||
] as const) {
|
||||
expect(DEFAULT_PRIORITY_CONFIG[tier]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("CRITICAL fans out to >=2 transports", () => {
|
||||
const router = new PriorityRouter();
|
||||
expect(router.isRedundant("CRITICAL")).toBe(true);
|
||||
expect(
|
||||
router.configFor("CRITICAL").minTransports
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("non-CRITICAL tiers are single-transport by default", () => {
|
||||
const router = new PriorityRouter();
|
||||
for (const tier of ["HIGH", "NORMAL", "LOW", "BULK"] as const) {
|
||||
expect(router.isRedundant(tier)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("dedups duplicate ids on CRITICAL only", () => {
|
||||
const router = new PriorityRouter();
|
||||
expect(router.shouldDeliver("CRITICAL", "x")).toBe(true);
|
||||
expect(router.shouldDeliver("CRITICAL", "x")).toBe(false);
|
||||
// NORMAL is dedup:false → always deliver
|
||||
expect(router.shouldDeliver("NORMAL", "y")).toBe(true);
|
||||
expect(router.shouldDeliver("NORMAL", "y")).toBe(true);
|
||||
});
|
||||
|
||||
it("evicts oldest dedup id past maxSeenPerTier", () => {
|
||||
const router = new PriorityRouter({}, { maxSeenPerTier: 2 });
|
||||
router.shouldDeliver("CRITICAL", "a");
|
||||
router.shouldDeliver("CRITICAL", "b");
|
||||
router.shouldDeliver("CRITICAL", "c");
|
||||
// 'a' should have been evicted, so it can be delivered again
|
||||
expect(router.shouldDeliver("CRITICAL", "a")).toBe(true);
|
||||
});
|
||||
|
||||
it("reset clears all dedup state", () => {
|
||||
const router = new PriorityRouter();
|
||||
router.shouldDeliver("CRITICAL", "x");
|
||||
router.reset();
|
||||
expect(router.shouldDeliver("CRITICAL", "x")).toBe(true);
|
||||
});
|
||||
|
||||
it("configFor throws PRIORITY_UNKNOWN for an invalid tier", () => {
|
||||
const router = new PriorityRouter();
|
||||
try {
|
||||
router.configFor("INVALID" as never);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.PRIORITY_UNKNOWN
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("merges user overrides with defaults", () => {
|
||||
const router = new PriorityRouter({
|
||||
BULK: { minTransports: 1, maxTransports: 5, dedup: true },
|
||||
});
|
||||
expect(router.configFor("BULK").maxTransports).toBe(5);
|
||||
expect(router.configFor("CRITICAL").minTransports).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
# `ConnectionRecoveryManager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Generates exponential-backoff delays with bounded full-jitter so the manager can pace reconnection attempts without thundering-herd or starving CRITICAL traffic forever. Encapsulates the state machine of "current attempt count" so callers do not have to track it themselves; resetting on a successful reconnect re-arms the curve from zero.
|
||||
|
||||
## Features
|
||||
|
||||
- Exponential growth (`base * factor ^ (attempt - 1)`) with a hard upper bound (`maxMs`).
|
||||
- Bounded jitter centred on the exponential ceiling so two peers reconnecting after the same outage do not collide on the second.
|
||||
- Deterministic via injectable `rng` so tests can pin sequences exactly.
|
||||
- `attempts()` getter and `reset()` exposed for higher-level orchestration (e.g. record a counter or roll back state on success).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ConnectionRecoveryManager } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const recovery = new ConnectionRecoveryManager();
|
||||
const first = recovery.nextDelay(); // ~250 ms ± jitter
|
||||
const second = recovery.nextDelay(); // ~500 ms ± jitter
|
||||
const third = recovery.nextDelay(); // ~1000 ms ± jitter
|
||||
recovery.reset();
|
||||
recovery.nextDelay().attempt; // 1 — sequence restarts
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ConnectionRecoveryManager } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const sequence: number[] = [];
|
||||
const recovery = new ConnectionRecoveryManager({
|
||||
baseMs: 100,
|
||||
maxMs: 5_000,
|
||||
factor: 1.8,
|
||||
jitter: 0.3,
|
||||
rng: () => 0.5, // deterministic — half-jitter centre
|
||||
});
|
||||
|
||||
while (recovery.attempts() < 8) {
|
||||
sequence.push(recovery.nextDelay().delayMs);
|
||||
}
|
||||
// sequence climbs 100 → 180 → 324 → ... and caps at 5000
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ConnectionRecoveryManager(config?)`
|
||||
- `nextDelay(): RecoverySchedule` — `{ attempt, delayMs, capped }`. Always advances the counter.
|
||||
- `attempts(): number` — current attempt count.
|
||||
- `reset(): void` — restart the sequence from attempt 0.
|
||||
- `interface ConnectionRecoveryManagerConfig` — `{ baseMs?, maxMs?, factor?, jitter?, rng? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Jitter is "full" relative to the exponential ceiling: the actual delay lands in `[ceiling * (1 - jitter), ceiling * (1 + jitter)]`, then clamped to `[0, maxMs]`. With the default `jitter = 0.5`, two peers reconnecting will land in disjoint half-second windows on average.
|
||||
- The schedule advances on every `nextDelay()` call regardless of whether the caller actually used the result. This is intentional — callers that abandon a schedule should `reset()` so the next `nextDelay()` does not start from a high attempt number.
|
||||
- `clamp01(jitter)` defends against misconfiguration: a `jitter` of 1.5 would otherwise produce negative delays, which would be silently treated as zero by `setTimeout`.
|
||||
- The class is intentionally state-only — actually scheduling the timer lives in the recovery flow that owns the `setTimeout`/`AbortSignal` lifetime.
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Per-attempt schedule emitted by `nextDelay(...)`. Callers act on
|
||||
* `delayMs` to space their reconnect attempts; `attempt` is included
|
||||
* for logging.
|
||||
*/
|
||||
export interface RecoverySchedule {
|
||||
readonly attempt: number;
|
||||
readonly delayMs: number;
|
||||
readonly capped: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionRecoveryManagerConfig {
|
||||
/** First-attempt delay. Defaults to 250 ms. */
|
||||
readonly baseMs?: number;
|
||||
/** Hard cap. Defaults to 30 s. */
|
||||
readonly maxMs?: number;
|
||||
/** Backoff multiplier per attempt. Defaults to 2. */
|
||||
readonly factor?: number;
|
||||
/** Jitter ratio in [0, 1]. Defaults to 0.5 (full-jitter half-mix). */
|
||||
readonly jitter?: number;
|
||||
/** Random source — injectable for deterministic tests. */
|
||||
readonly rng?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the exponential backoff delay sequence with bounded
|
||||
* full-jitter. Stateful: each call to `nextDelay()` advances the
|
||||
* internal attempt counter so callers can stay synchronous with the
|
||||
* schedule without bookkeeping.
|
||||
*/
|
||||
export class ConnectionRecoveryManager {
|
||||
private readonly baseMs: number;
|
||||
private readonly maxMs: number;
|
||||
private readonly factor: number;
|
||||
private readonly jitter: number;
|
||||
private readonly rng: () => number;
|
||||
private attempt = 0;
|
||||
|
||||
public constructor(config: ConnectionRecoveryManagerConfig = {}) {
|
||||
this.baseMs = config.baseMs ?? 250;
|
||||
this.maxMs = config.maxMs ?? 30_000;
|
||||
this.factor = config.factor ?? 2;
|
||||
this.jitter = clamp01(config.jitter ?? 0.5);
|
||||
this.rng = config.rng ?? Math.random;
|
||||
}
|
||||
|
||||
public nextDelay(): RecoverySchedule {
|
||||
this.attempt += 1;
|
||||
const exp = this.baseMs * this.factor ** (this.attempt - 1);
|
||||
const capped = exp >= this.maxMs;
|
||||
const ceiling = capped ? this.maxMs : exp;
|
||||
const jitterRange = ceiling * this.jitter;
|
||||
const jittered = ceiling - jitterRange + this.rng() * jitterRange * 2;
|
||||
const finalDelay = Math.min(this.maxMs, Math.max(0, jittered));
|
||||
return { attempt: this.attempt, delayMs: finalDelay, capped };
|
||||
}
|
||||
|
||||
public attempts(): number {
|
||||
return this.attempt;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.attempt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TM_CODES, type NetworkTmError } from "../error-codes.js";
|
||||
import { SessionPersistenceBuffer } from "../persistence/session-persistence-buffer.js";
|
||||
import { ConnectionRecoveryManager } from "./connection-recovery-manager.js";
|
||||
import { SessionContinuityGuard } from "./session-continuity-guard.js";
|
||||
|
||||
describe("NET-60 — ConnectionRecoveryManager backoff + jitter", () => {
|
||||
it("produces an exponentially-growing sequence under deterministic rng", () => {
|
||||
const recovery = new ConnectionRecoveryManager({
|
||||
baseMs: 100,
|
||||
maxMs: 10_000,
|
||||
factor: 2,
|
||||
jitter: 0,
|
||||
rng: () => 0,
|
||||
});
|
||||
const seq = [
|
||||
recovery.nextDelay().delayMs,
|
||||
recovery.nextDelay().delayMs,
|
||||
recovery.nextDelay().delayMs,
|
||||
];
|
||||
expect(seq).toEqual([100, 200, 400]);
|
||||
});
|
||||
|
||||
it("caps at maxMs once the exponential exceeds it", () => {
|
||||
const recovery = new ConnectionRecoveryManager({
|
||||
baseMs: 1_000,
|
||||
maxMs: 3_000,
|
||||
factor: 4,
|
||||
jitter: 0,
|
||||
rng: () => 0,
|
||||
});
|
||||
recovery.nextDelay(); // 1000
|
||||
recovery.nextDelay(); // 4000 -> capped at 3000
|
||||
const third = recovery.nextDelay();
|
||||
expect(third.capped).toBe(true);
|
||||
expect(third.delayMs).toBe(3_000);
|
||||
});
|
||||
|
||||
it("applies bounded full-jitter symmetrically", () => {
|
||||
const lowSeed = 0;
|
||||
const highSeed = 1;
|
||||
const make = (rng: () => number) =>
|
||||
new ConnectionRecoveryManager({
|
||||
baseMs: 1_000,
|
||||
maxMs: 10_000,
|
||||
factor: 1,
|
||||
jitter: 0.5,
|
||||
rng,
|
||||
});
|
||||
const low = make(() => lowSeed).nextDelay().delayMs;
|
||||
const high = make(() => highSeed).nextDelay().delayMs;
|
||||
expect(low).toBe(500);
|
||||
expect(high).toBe(1500);
|
||||
});
|
||||
|
||||
it("reset restarts the schedule from attempt 1", () => {
|
||||
const recovery = new ConnectionRecoveryManager({
|
||||
baseMs: 100,
|
||||
maxMs: 10_000,
|
||||
factor: 2,
|
||||
jitter: 0,
|
||||
rng: () => 0,
|
||||
});
|
||||
recovery.nextDelay();
|
||||
recovery.nextDelay();
|
||||
recovery.reset();
|
||||
const next = recovery.nextDelay();
|
||||
expect(next.attempt).toBe(1);
|
||||
expect(next.delayMs).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps jitter ratio out of [0,1] silently", () => {
|
||||
const r1 = new ConnectionRecoveryManager({ jitter: -1, rng: () => 0 });
|
||||
const r2 = new ConnectionRecoveryManager({ jitter: 5, rng: () => 0 });
|
||||
// Just confirm both produce a finite, non-negative number.
|
||||
const d1 = r1.nextDelay().delayMs;
|
||||
const d2 = r2.nextDelay().delayMs;
|
||||
expect(Number.isFinite(d1) && d1 >= 0).toBe(true);
|
||||
expect(Number.isFinite(d2) && d2 >= 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NET-60 — SessionContinuityGuard state machine + prioritized replay", () => {
|
||||
it("rejects bufferOutbound while connected", () => {
|
||||
const buffer = new SessionPersistenceBuffer<string>();
|
||||
const guard = new SessionContinuityGuard(buffer);
|
||||
try {
|
||||
guard.bufferOutbound({ id: "x", priority: "NORMAL", payload: "" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.INVALID_TRANSITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects enterRecovering before enterBuffering", () => {
|
||||
const guard = new SessionContinuityGuard(
|
||||
new SessionPersistenceBuffer<string>()
|
||||
);
|
||||
try {
|
||||
guard.enterRecovering();
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.INVALID_TRANSITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects enterBuffering twice", () => {
|
||||
const guard = new SessionContinuityGuard(
|
||||
new SessionPersistenceBuffer<string>()
|
||||
);
|
||||
guard.enterBuffering();
|
||||
try {
|
||||
guard.enterBuffering();
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.INVALID_TRANSITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("emits state-change events on every transition", () => {
|
||||
const events: { from: string; to: string }[] = [];
|
||||
const guard = new SessionContinuityGuard(
|
||||
new SessionPersistenceBuffer<string>(),
|
||||
{
|
||||
emit: (_event, payload) =>
|
||||
events.push({ from: payload.from, to: payload.to }),
|
||||
},
|
||||
{ clock: () => 1 }
|
||||
);
|
||||
guard.enterBuffering();
|
||||
guard.enterRecovering();
|
||||
guard.markConnected();
|
||||
expect(events.map((e) => e.to)).toEqual([
|
||||
"buffering",
|
||||
"recovering",
|
||||
"connected",
|
||||
]);
|
||||
});
|
||||
|
||||
it("markConnected is idempotent when already connected", () => {
|
||||
const events: unknown[] = [];
|
||||
const guard = new SessionContinuityGuard(
|
||||
new SessionPersistenceBuffer<string>(),
|
||||
{ emit: (event, payload) => events.push({ event, payload }) }
|
||||
);
|
||||
guard.markConnected();
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
it("CRITICAL replays before NORMAL/BULK", () => {
|
||||
const guard = new SessionContinuityGuard(
|
||||
new SessionPersistenceBuffer<string>()
|
||||
);
|
||||
guard.enterBuffering();
|
||||
guard.bufferOutbound({ id: "n", priority: "NORMAL", payload: "" });
|
||||
guard.bufferOutbound({ id: "c", priority: "CRITICAL", payload: "" });
|
||||
guard.bufferOutbound({ id: "b", priority: "BULK", payload: "" });
|
||||
const drained = guard.enterRecovering();
|
||||
expect(drained.map((m) => m.id)).toEqual(["c", "n", "b"]);
|
||||
guard.markConnected();
|
||||
expect(guard.currentState()).toBe("connected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
# `SessionContinuityGuard`
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the session-state machine around the persistence buffer so callers cannot bypass the documented sequence: a session must enter `buffering` before it can `recover`, and only the recovering state drains the buffer for replay. Centralising this logic keeps the manager free of ad-hoc state checks scattered across modules.
|
||||
|
||||
## Features
|
||||
|
||||
- Three explicit states (`connected`, `buffering`, `recovering`) with strict transition guards backed by `INVALID_TRANSITION`.
|
||||
- `enterRecovering()` returns the priority-ordered drain in a single atomic step so callers cannot replay against a half-cleared buffer.
|
||||
- Optional `BusLike` for `session:state-change` observability events; injectable clock for deterministic tests.
|
||||
- `bufferOutbound(...)` gateway refuses to buffer while the session is still connected — that would silently swallow messages.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SessionPersistenceBuffer,
|
||||
SessionContinuityGuard,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const buffer = new SessionPersistenceBuffer<string>();
|
||||
const guard = new SessionContinuityGuard(buffer);
|
||||
|
||||
guard.enterBuffering();
|
||||
guard.bufferOutbound({ id: "m1", priority: "CRITICAL", payload: "vote" });
|
||||
|
||||
// Reconnect: drain in priority order, then mark connected.
|
||||
const replay = guard.enterRecovering();
|
||||
replay[0].id; // "m1"
|
||||
guard.markConnected();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SessionPersistenceBuffer,
|
||||
SessionContinuityGuard,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const events: unknown[] = [];
|
||||
const bus = {
|
||||
emit: (event: string, payload: unknown) => events.push({ event, payload }),
|
||||
};
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const buffer = new SessionPersistenceBuffer<string>();
|
||||
const guard = new SessionContinuityGuard(buffer, bus, { clock: () => now });
|
||||
|
||||
try {
|
||||
guard.bufferOutbound({ id: "x", priority: "NORMAL", payload: "" });
|
||||
} catch (err) {
|
||||
// INVALID_TRANSITION — must enter buffering first
|
||||
}
|
||||
|
||||
guard.enterBuffering();
|
||||
now += 5;
|
||||
guard.bufferOutbound({ id: "y", priority: "NORMAL", payload: "" });
|
||||
const drained = guard.enterRecovering();
|
||||
guard.markConnected();
|
||||
events.length; // 3 — buffering, recovering, connected
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SessionContinuityGuard<TPayload = unknown>(buffer, bus?, config?)`
|
||||
- `currentState(): SessionState`.
|
||||
- `enterBuffering(): void` — throws `INVALID_TRANSITION` from non-connected states.
|
||||
- `enterRecovering(): ReadonlyArray<BufferedMessage<TPayload>>` — throws `INVALID_TRANSITION` from non-buffering states; returns the drain in priority-then-FIFO order.
|
||||
- `markConnected(): void` — idempotent no-op when already connected.
|
||||
- `bufferOutbound({ id, priority, payload }): BufferedMessage<TPayload>` — throws `INVALID_TRANSITION` when called from `connected`.
|
||||
- `type SessionState` — `"connected" | "buffering" | "recovering"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Transitions are deliberately one-directional except for the `recovering → connected → buffering` re-entry path, which mirrors the wire-level reality: a session that recovers fully can later enter buffering again on a new drop.
|
||||
- The guard does not own a timer of its own — `ConnectionRecoveryManager` handles delays. Splitting the concerns keeps each class trivially unit-testable.
|
||||
- `markConnected` is idempotent so handlers wired to multiple sources (e.g. a peer-event listener AND a transport-event listener) cannot trip the guard by both calling it.
|
||||
- `bufferOutbound` could quietly send-through when `state === "connected"`, but failing loudly catches caller bugs much earlier than a silent fallback would.
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import type {
|
||||
BufferedMessage,
|
||||
SessionPersistenceBuffer,
|
||||
} from "../persistence/session-persistence-buffer.js";
|
||||
|
||||
export type SessionState = "connected" | "buffering" | "recovering";
|
||||
|
||||
export interface SessionContinuityGuardConfig {
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
interface BusLike {
|
||||
emit(
|
||||
event: "session:state-change",
|
||||
payload: { from: SessionState; to: SessionState; at: number }
|
||||
): void;
|
||||
}
|
||||
|
||||
const NULL_BUS: BusLike = { emit: () => undefined };
|
||||
|
||||
/**
|
||||
* Enforces the connected → buffering → recovering → connected lifecycle
|
||||
* around a `SessionPersistenceBuffer`. Rejects out-of-order transitions
|
||||
* with `INVALID_TRANSITION` so caller bugs are loud, and replays the
|
||||
* buffered messages in priority order on `enterRecovering`.
|
||||
*/
|
||||
export class SessionContinuityGuard<TPayload = unknown> {
|
||||
private readonly buffer: SessionPersistenceBuffer<TPayload>;
|
||||
private readonly bus: BusLike;
|
||||
private readonly clock: () => number;
|
||||
private state: SessionState = "connected";
|
||||
|
||||
public constructor(
|
||||
buffer: SessionPersistenceBuffer<TPayload>,
|
||||
bus: BusLike | null = null,
|
||||
config: SessionContinuityGuardConfig = {}
|
||||
) {
|
||||
this.buffer = buffer;
|
||||
this.bus = bus ?? NULL_BUS;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public currentState(): SessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public enterBuffering(): void {
|
||||
if (this.state !== "connected") {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.INVALID_TRANSITION,
|
||||
"session.invalid-buffering",
|
||||
{ context: { from: this.state } }
|
||||
);
|
||||
}
|
||||
this.transition("buffering");
|
||||
}
|
||||
|
||||
public enterRecovering(): ReadonlyArray<BufferedMessage<TPayload>> {
|
||||
if (this.state !== "buffering") {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.INVALID_TRANSITION,
|
||||
"session.invalid-recovering",
|
||||
{ context: { from: this.state } }
|
||||
);
|
||||
}
|
||||
this.transition("recovering");
|
||||
return this.buffer.drain();
|
||||
}
|
||||
|
||||
public markConnected(): void {
|
||||
if (this.state === "connected") return;
|
||||
this.transition("connected");
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller's enqueue gateway — refuses to buffer when the session is
|
||||
* still connected (callers should send through the live transport),
|
||||
* delegates to the buffer otherwise.
|
||||
*/
|
||||
public bufferOutbound(input: {
|
||||
id: string;
|
||||
priority: BufferedMessage<TPayload>["priority"];
|
||||
payload: TPayload;
|
||||
}): BufferedMessage<TPayload> {
|
||||
if (this.state === "connected") {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.INVALID_TRANSITION,
|
||||
"session.buffer-while-connected",
|
||||
{ context: { state: this.state } }
|
||||
);
|
||||
}
|
||||
return this.buffer.enqueue(input);
|
||||
}
|
||||
|
||||
private transition(to: SessionState): void {
|
||||
const from = this.state;
|
||||
this.state = to;
|
||||
this.bus.emit("session:state-change", {
|
||||
from,
|
||||
to,
|
||||
at: this.clock(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# `BandwidthConservator`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `NET-124`: when the manager is told it has a constrained bandwidth budget, this module reports the recommended message-coalescing interval, decides whether the redundant copy of a CRITICAL message should be suppressed, and gates per-priority coalescing. Like `BatteryAwareScheduler`, it is advisory — callers honour the recommendations rather than having timers fire from inside the conservator.
|
||||
|
||||
## Features
|
||||
|
||||
- Two-tier coalescing: normal conservation uses the configured batch interval; bytes/sec below the "extreme" threshold quadruples the interval.
|
||||
- Always flushes CRITICAL immediately even when coalescing is on so heartbeats and election votes do not sit in batches.
|
||||
- Optional suppression of redundant CRITICAL fan-out when the budget is extreme — better to send once than to drop the message because the budget burned out on duplicates.
|
||||
- Inert when `conserveActive: false` (the production fast-path) so the conservator costs nothing in normal operation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BandwidthConservator } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const conservator = new BandwidthConservator();
|
||||
conservator.update({ conserveActive: true, bytesPerSec: 32 * 1024 });
|
||||
conservator.batchInterval(); // 200 ms
|
||||
conservator.shouldCoalesce("NORMAL"); // true
|
||||
conservator.shouldCoalesce("CRITICAL"); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BandwidthConservator } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const conservator = new BandwidthConservator({
|
||||
batchIntervalMs: 250,
|
||||
extremeBatchMultiplier: 6,
|
||||
extremeBytesPerSec: 2_048,
|
||||
});
|
||||
|
||||
// Tethered to a slow link
|
||||
conservator.update({ conserveActive: true, bytesPerSec: 1_024 });
|
||||
conservator.batchInterval(); // 1500 ms — extreme
|
||||
conservator.suppressRedundantCritical(); // true — drop CRITICAL duplicates
|
||||
|
||||
// Back on Wi-Fi
|
||||
conservator.update({ conserveActive: false, bytesPerSec: 10_000_000 });
|
||||
conservator.batchInterval(); // 0 — no batching
|
||||
conservator.suppressRedundantCritical(); // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BandwidthConservator(config?)`
|
||||
- `update(signal: BandwidthSignal): void`.
|
||||
- `currentSignal(): BandwidthSignal`.
|
||||
- `batchInterval(): number` — `0` when inactive, `batchIntervalMs` otherwise, multiplied at extreme budgets.
|
||||
- `suppressRedundantCritical(): boolean` — only `true` when active AND extreme.
|
||||
- `shouldCoalesce(priority: MessagePriority): boolean` — `false` for CRITICAL always; `false` when inactive; `true` for the other tiers under conservation.
|
||||
- `interface BandwidthSignal` — `{ conserveActive, bytesPerSec }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `bytesPerSec` is a single advisory number; the conservator does not enforce it. Real enforcement (token-bucket, write-side throttling) is done elsewhere in the dispatcher pipeline. Splitting policy from enforcement keeps each module trivially testable.
|
||||
- The "extreme" threshold defaults to 4 KiB/s — roughly the floor where modern protocols (HTTPS handshakes, ICE keepalive) become uncomfortable. Tune via `extremeBytesPerSec` for deployment-specific links (e.g. satellite).
|
||||
- CRITICAL is the one priority that opts out of coalescing because batching a CRITICAL message into a delayed flush window negates the entire point of the priority tier.
|
||||
- `suppressRedundantCritical` is intentionally *not* the inverse of "`isRedundant("CRITICAL")` in the priority router". The router decides what the system *would* like to do; the conservator decides what the system can *afford* to do. Both decisions feed the dispatcher.
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { MessagePriority } from "../types/priority.types.js";
|
||||
|
||||
export interface BandwidthSignal {
|
||||
/** True when conservation mode is active. */
|
||||
readonly conserveActive: boolean;
|
||||
/** Bytes/sec budget the manager is allowed to spend. */
|
||||
readonly bytesPerSec: number;
|
||||
}
|
||||
|
||||
export interface BandwidthConservatorConfig {
|
||||
/** Default per-batch coalescing window when conserving. Defaults to 200 ms. */
|
||||
readonly batchIntervalMs?: number;
|
||||
/** Multiplier applied to the batch interval at extreme conservation. Defaults to 4. */
|
||||
readonly extremeBatchMultiplier?: number;
|
||||
/** Bytes/sec at/below which we treat as extreme conservation. Defaults to 4 KiB/s. */
|
||||
readonly extremeBytesPerSec?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the manager's recommended batching interval and whether to
|
||||
* suppress redundant CRITICAL fan-out, given the current bandwidth
|
||||
* signal. Inert when conservation is disabled.
|
||||
*/
|
||||
export class BandwidthConservator {
|
||||
private readonly batchIntervalMs: number;
|
||||
private readonly extremeBatchMultiplier: number;
|
||||
private readonly extremeBytesPerSec: number;
|
||||
private signal: BandwidthSignal = {
|
||||
conserveActive: false,
|
||||
bytesPerSec: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
|
||||
public constructor(config: BandwidthConservatorConfig = {}) {
|
||||
this.batchIntervalMs = config.batchIntervalMs ?? 200;
|
||||
this.extremeBatchMultiplier = config.extremeBatchMultiplier ?? 4;
|
||||
this.extremeBytesPerSec = config.extremeBytesPerSec ?? 4 * 1024;
|
||||
}
|
||||
|
||||
public update(signal: BandwidthSignal): void {
|
||||
this.signal = signal;
|
||||
}
|
||||
|
||||
public currentSignal(): BandwidthSignal {
|
||||
return this.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommended batch interval. Returns `0` (no batching) when
|
||||
* conservation is inactive.
|
||||
*/
|
||||
public batchInterval(): number {
|
||||
if (!this.signal.conserveActive) return 0;
|
||||
if (this.signal.bytesPerSec <= this.extremeBytesPerSec) {
|
||||
return this.batchIntervalMs * this.extremeBatchMultiplier;
|
||||
}
|
||||
return this.batchIntervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the manager skip the redundant copy of a CRITICAL message?
|
||||
* Returns true only when conservation is active AND the bandwidth
|
||||
* budget is "extreme".
|
||||
*/
|
||||
public suppressRedundantCritical(): boolean {
|
||||
return (
|
||||
this.signal.conserveActive &&
|
||||
this.signal.bytesPerSec <= this.extremeBytesPerSec
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a single message of the given priority should be
|
||||
* coalesced into the next batch instead of flushed immediately.
|
||||
* CRITICAL is always flushed immediately so heartbeats / votes
|
||||
* never sit in a batch.
|
||||
*/
|
||||
public shouldCoalesce(priority: MessagePriority): boolean {
|
||||
if (!this.signal.conserveActive) return false;
|
||||
if (priority === "CRITICAL") return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BandwidthConservator } from "./bandwidth-conservator.js";
|
||||
|
||||
describe("NET-124 — BandwidthConservator coalesces & suppresses redundancy", () => {
|
||||
it("inert when conservation is off", () => {
|
||||
const c = new BandwidthConservator();
|
||||
c.update({
|
||||
conserveActive: false,
|
||||
bytesPerSec: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
expect(c.batchInterval()).toBe(0);
|
||||
expect(c.suppressRedundantCritical()).toBe(false);
|
||||
expect(c.shouldCoalesce("NORMAL")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns base interval at moderate conservation", () => {
|
||||
const c = new BandwidthConservator({ batchIntervalMs: 250 });
|
||||
c.update({ conserveActive: true, bytesPerSec: 32_000 });
|
||||
expect(c.batchInterval()).toBe(250);
|
||||
expect(c.suppressRedundantCritical()).toBe(false);
|
||||
});
|
||||
|
||||
it("multiplies batch interval at extreme conservation", () => {
|
||||
const c = new BandwidthConservator({
|
||||
batchIntervalMs: 200,
|
||||
extremeBatchMultiplier: 4,
|
||||
extremeBytesPerSec: 4 * 1024,
|
||||
});
|
||||
c.update({ conserveActive: true, bytesPerSec: 1_024 });
|
||||
expect(c.batchInterval()).toBe(800);
|
||||
expect(c.suppressRedundantCritical()).toBe(true);
|
||||
});
|
||||
|
||||
it("CRITICAL never coalesces", () => {
|
||||
const c = new BandwidthConservator();
|
||||
c.update({ conserveActive: true, bytesPerSec: 32_000 });
|
||||
expect(c.shouldCoalesce("CRITICAL")).toBe(false);
|
||||
expect(c.shouldCoalesce("NORMAL")).toBe(true);
|
||||
expect(c.shouldCoalesce("BULK")).toBe(true);
|
||||
});
|
||||
|
||||
it("currentSignal reports the latest update", () => {
|
||||
const c = new BandwidthConservator();
|
||||
c.update({ conserveActive: true, bytesPerSec: 1_000 });
|
||||
expect(c.currentSignal().bytesPerSec).toBe(1_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
# `BatteryAwareScheduler`
|
||||
|
||||
## Purpose
|
||||
|
||||
Reduces keepalive frequency and prefers low-power transports when the host device is running on battery. Implements `NET-123` — a mobile peer should not burn 30 % of its remaining charge on WebRTC ICE keepalives when WebSocket would carry the same traffic for half the energy budget.
|
||||
|
||||
## Features
|
||||
|
||||
- Two backoff multipliers: a moderate one above the low-battery threshold and an aggressive one at/below it.
|
||||
- Charging state respected: even on battery, plugged-in laptops use the AC keepalive cadence.
|
||||
- `preferLowPower()` returns transports in `highPower: false` first order so the manager rotates through cheap paths before expensive ones.
|
||||
- Pure advisory API — no timers scheduled internally. Callers retain control over scheduling primitives so the policy stays test-friendly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
BatteryAwareScheduler,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
const scheduler = new BatteryAwareScheduler(pool);
|
||||
|
||||
scheduler.update({ onBattery: true, level: 0.65, charging: false });
|
||||
scheduler.keepaliveMs(); // 10_000 — 2× the AC cadence
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
BatteryAwareScheduler,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true, highPower: true });
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
|
||||
const scheduler = new BatteryAwareScheduler(pool, {
|
||||
baseKeepaliveMs: 4_000,
|
||||
batteryMultiplier: 3,
|
||||
lowBatteryMultiplier: 6,
|
||||
lowBatteryAt: 0.15,
|
||||
});
|
||||
|
||||
scheduler.update({ onBattery: true, level: 0.10, charging: false });
|
||||
scheduler.keepaliveMs(); // 24_000 — aggressive backoff
|
||||
scheduler.preferLowPower()[0].id; // "ws" — low-power first
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BatteryAwareScheduler(pool, config?)`
|
||||
- `update(signal: BatterySignal): void`.
|
||||
- `currentSignal(): BatterySignal`.
|
||||
- `keepaliveMs(): number` — current recommended keepalive interval.
|
||||
- `preferLowPower(): ReadonlyArray<TransportLike>` — healthy transports reordered low-power first when on battery.
|
||||
- `interface BatterySignal` — `{ onBattery, level, charging }`.
|
||||
- `interface BatteryAwareSchedulerConfig` — `{ baseKeepaliveMs?, batteryMultiplier?, lowBatteryMultiplier?, lowBatteryAt? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The scheduler is *advisory*: it does not subscribe to OS-level battery events, schedule timers, or pause keepalive probes itself. Callers integrate it with their own probe scheduler so the production code path matches the test harness exactly.
|
||||
- `charging: true` short-circuits to the AC cadence even when `onBattery: true` — a laptop topped up at the desk is effectively on AC for the duration of the session.
|
||||
- The reorder is stable: ties (two non-high-power transports) keep the pool insertion order so callers get deterministic rotation across calls when the battery signal does not change.
|
||||
- The `highPower` hint is opt-in (`undefined` means "not specified"). Transports without the hint are treated as low-power, which is the safer default — the worst case is using a slightly hungrier transport than necessary, not skipping a critical path.
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { TransportPool } from "../pool/transport-pool.js";
|
||||
import type { TransportLike } from "../types/transport.types.js";
|
||||
|
||||
/**
|
||||
* Snapshot of the host device's power state. Sources are platform-
|
||||
* specific (browser `navigator.getBattery()`, Tauri/Electron native
|
||||
* APIs, server "always on"). The scheduler only consumes the abstract
|
||||
* shape here.
|
||||
*/
|
||||
export interface BatterySignal {
|
||||
/** True when the device is running on battery power. */
|
||||
readonly onBattery: boolean;
|
||||
/** Battery level in [0, 1]. 1 means full. */
|
||||
readonly level: number;
|
||||
/** True when the device is currently charging. */
|
||||
readonly charging: boolean;
|
||||
}
|
||||
|
||||
export interface BatteryAwareSchedulerConfig {
|
||||
/** Default keepalive interval when on AC power. Defaults to 5 s. */
|
||||
readonly baseKeepaliveMs?: number;
|
||||
/** Multiplier applied when on battery, level above lowBatteryAt. Defaults to 2. */
|
||||
readonly batteryMultiplier?: number;
|
||||
/** Multiplier applied when on battery, level at/below lowBatteryAt. Defaults to 4. */
|
||||
readonly lowBatteryMultiplier?: number;
|
||||
/** Battery level (0..1) considered "low". Defaults to 0.2. */
|
||||
readonly lowBatteryAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts transport keepalive cadence and prefers low-power transports
|
||||
* when the host runs on battery. The scheduler is purely advisory —
|
||||
* it does not actually schedule timers; callers consume the
|
||||
* recommended interval and reorder list.
|
||||
*/
|
||||
export class BatteryAwareScheduler {
|
||||
private readonly pool: TransportPool;
|
||||
private readonly baseKeepaliveMs: number;
|
||||
private readonly batteryMultiplier: number;
|
||||
private readonly lowBatteryMultiplier: number;
|
||||
private readonly lowBatteryAt: number;
|
||||
private signal: BatterySignal = {
|
||||
onBattery: false,
|
||||
level: 1,
|
||||
charging: true,
|
||||
};
|
||||
|
||||
public constructor(
|
||||
pool: TransportPool,
|
||||
config: BatteryAwareSchedulerConfig = {}
|
||||
) {
|
||||
this.pool = pool;
|
||||
this.baseKeepaliveMs = config.baseKeepaliveMs ?? 5_000;
|
||||
this.batteryMultiplier = config.batteryMultiplier ?? 2;
|
||||
this.lowBatteryMultiplier = config.lowBatteryMultiplier ?? 4;
|
||||
this.lowBatteryAt = config.lowBatteryAt ?? 0.2;
|
||||
}
|
||||
|
||||
public update(signal: BatterySignal): void {
|
||||
this.signal = signal;
|
||||
}
|
||||
|
||||
public currentSignal(): BatterySignal {
|
||||
return this.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommended keepalive interval for the current power state.
|
||||
* Callers feed the result into their probe scheduler.
|
||||
*/
|
||||
public keepaliveMs(): number {
|
||||
if (!this.signal.onBattery || this.signal.charging) {
|
||||
return this.baseKeepaliveMs;
|
||||
}
|
||||
if (this.signal.level <= this.lowBatteryAt) {
|
||||
return this.baseKeepaliveMs * this.lowBatteryMultiplier;
|
||||
}
|
||||
return this.baseKeepaliveMs * this.batteryMultiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder healthy transports to prefer low-power options when the
|
||||
* device is on battery; falls back to insertion order on AC power.
|
||||
*/
|
||||
public preferLowPower(): ReadonlyArray<TransportLike> {
|
||||
const transports = this.pool
|
||||
.listHealthy()
|
||||
.map((entry) => entry.transport);
|
||||
if (!this.signal.onBattery || this.signal.charging) {
|
||||
return transports;
|
||||
}
|
||||
return [...transports].sort((a, b) => {
|
||||
const aHigh = a.highPower === true ? 1 : 0;
|
||||
const bHigh = b.highPower === true ? 1 : 0;
|
||||
return aHigh - bHigh;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TransportPool } from "../pool/transport-pool.js";
|
||||
import { BatteryAwareScheduler } from "./battery-aware-scheduler.js";
|
||||
|
||||
describe("NET-123 — BatteryAwareScheduler conserves power", () => {
|
||||
it("uses base keepalive on AC power", () => {
|
||||
const pool = new TransportPool();
|
||||
const sched = new BatteryAwareScheduler(pool, {
|
||||
baseKeepaliveMs: 5_000,
|
||||
});
|
||||
sched.update({ onBattery: false, level: 1, charging: true });
|
||||
expect(sched.keepaliveMs()).toBe(5_000);
|
||||
});
|
||||
|
||||
it("uses charging-respecting cadence even when on-battery is reported", () => {
|
||||
const pool = new TransportPool();
|
||||
const sched = new BatteryAwareScheduler(pool, {
|
||||
baseKeepaliveMs: 5_000,
|
||||
});
|
||||
sched.update({ onBattery: true, level: 0.5, charging: true });
|
||||
expect(sched.keepaliveMs()).toBe(5_000);
|
||||
});
|
||||
|
||||
it("doubles keepalive on battery above the low threshold", () => {
|
||||
const pool = new TransportPool();
|
||||
const sched = new BatteryAwareScheduler(pool, {
|
||||
baseKeepaliveMs: 5_000,
|
||||
batteryMultiplier: 2,
|
||||
lowBatteryAt: 0.2,
|
||||
});
|
||||
sched.update({ onBattery: true, level: 0.5, charging: false });
|
||||
expect(sched.keepaliveMs()).toBe(10_000);
|
||||
});
|
||||
|
||||
it("aggressively backs off keepalive at/below low-battery threshold", () => {
|
||||
const pool = new TransportPool();
|
||||
const sched = new BatteryAwareScheduler(pool, {
|
||||
baseKeepaliveMs: 5_000,
|
||||
lowBatteryMultiplier: 4,
|
||||
lowBatteryAt: 0.2,
|
||||
});
|
||||
sched.update({ onBattery: true, level: 0.1, charging: false });
|
||||
expect(sched.keepaliveMs()).toBe(20_000);
|
||||
});
|
||||
|
||||
it("preferLowPower preserves order on AC power", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register({
|
||||
id: "wrtc",
|
||||
capability: "webrtc-datachannel",
|
||||
connected: true,
|
||||
highPower: true,
|
||||
});
|
||||
pool.register({
|
||||
id: "ws",
|
||||
capability: "websocket",
|
||||
connected: true,
|
||||
});
|
||||
const sched = new BatteryAwareScheduler(pool);
|
||||
sched.update({ onBattery: false, level: 1, charging: true });
|
||||
expect(sched.preferLowPower().map((t) => t.id)).toEqual(["wrtc", "ws"]);
|
||||
});
|
||||
|
||||
it("preferLowPower puts low-power transports first on battery", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register({
|
||||
id: "wrtc",
|
||||
capability: "webrtc-datachannel",
|
||||
connected: true,
|
||||
highPower: true,
|
||||
});
|
||||
pool.register({
|
||||
id: "ws",
|
||||
capability: "websocket",
|
||||
connected: true,
|
||||
});
|
||||
const sched = new BatteryAwareScheduler(pool);
|
||||
sched.update({ onBattery: true, level: 0.5, charging: false });
|
||||
expect(sched.preferLowPower().map((t) => t.id)).toEqual(["ws", "wrtc"]);
|
||||
});
|
||||
|
||||
it("currentSignal reports the latest update", () => {
|
||||
const sched = new BatteryAwareScheduler(new TransportPool());
|
||||
sched.update({ onBattery: true, level: 0.3, charging: false });
|
||||
expect(sched.currentSignal().level).toBe(0.3);
|
||||
});
|
||||
|
||||
it("preferLowPower keeps order when low-power was registered first", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register({
|
||||
id: "ws",
|
||||
capability: "websocket",
|
||||
connected: true,
|
||||
});
|
||||
pool.register({
|
||||
id: "wrtc",
|
||||
capability: "webrtc-datachannel",
|
||||
connected: true,
|
||||
highPower: true,
|
||||
});
|
||||
const sched = new BatteryAwareScheduler(pool);
|
||||
sched.update({ onBattery: true, level: 0.5, charging: false });
|
||||
expect(sched.preferLowPower().map((t) => t.id)).toEqual(["ws", "wrtc"]);
|
||||
});
|
||||
|
||||
it("preferLowPower is stable when no transport advertises highPower", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register({
|
||||
id: "ws-a",
|
||||
capability: "websocket",
|
||||
connected: true,
|
||||
});
|
||||
pool.register({
|
||||
id: "ws-b",
|
||||
capability: "websocket",
|
||||
connected: true,
|
||||
});
|
||||
const sched = new BatteryAwareScheduler(pool);
|
||||
sched.update({ onBattery: true, level: 0.5, charging: false });
|
||||
expect(sched.preferLowPower().map((t) => t.id)).toEqual([
|
||||
"ws-a",
|
||||
"ws-b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
# `PreemptiveSelector`
|
||||
|
||||
## Purpose
|
||||
|
||||
Decides whether to swap a still-functioning transport for a healthier alternative *before* the current one drops to below the pause threshold. Combines the `QualityTrendAnalyzer` (is the path getting worse?) with the `TransportPool` (do we have a better path?) and returns a `PreemptiveHandoff` describing how to transition with overlap so in-flight messages do not hit a black hole.
|
||||
|
||||
## Features
|
||||
|
||||
- Two-input gating: only proposes a swap when the current path is degrading **and** an alternative exists with at least `minScoreGap` more health.
|
||||
- Returns `null` for the safe default — leave the transport in place — so callers can use the result directly without re-implementing boolean logic.
|
||||
- Smooth-handoff plan: establish-before-teardown with a configurable overlap window (default 750 ms) for messages already in flight.
|
||||
- `requireBestAlternative` throws `NO_HEALTHY_TRANSPORT` for callers (e.g. emergency manual swap) that cannot tolerate a `null` return.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
QualityTrendAnalyzer,
|
||||
PreemptiveSelector,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
|
||||
// Feed a clean WebSocket vs degrading WebRTC profile
|
||||
analyzer.record("wrtc", { latency: 60, loss: 0, bandwidthBps: 1_000_000 });
|
||||
analyzer.record("wrtc", { latency: 220, loss: 0.05, bandwidthBps: 600_000 });
|
||||
pool.setHealth("wrtc", { rttMs: 220, lossRate: 0.05, stability: 0.7, score: 35, paused: false });
|
||||
pool.setHealth("ws", { rttMs: 70, lossRate: 0, stability: 1, score: 95, paused: false });
|
||||
|
||||
const plan = selector.evaluate("wrtc");
|
||||
// plan.fromTransportId === "wrtc"
|
||||
// plan.toTransport.id === "ws"
|
||||
// plan.overlapMs === 750
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const selector = new PreemptiveSelector(pool, analyzer, {
|
||||
overlapMs: 1500, // longer overlap for satellite paths
|
||||
minScoreGap: 25, // only swap when the alternative is materially better
|
||||
});
|
||||
|
||||
const plan = selector.evaluate(currentId);
|
||||
if (plan !== null) {
|
||||
await dial(plan.toTransport.id);
|
||||
setTimeout(() => teardown(plan.fromTransportId), plan.overlapMs);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PreemptiveSelector(pool, analyzer, config?)`
|
||||
- `evaluate(currentTransportId): PreemptiveHandoff | null` — `null` means "leave current path in place".
|
||||
- `requireBestAlternative(currentTransportId): { transport, score }` — throws `NO_HEALTHY_TRANSPORT` when no alternative exists.
|
||||
- `interface PreemptiveHandoff` — `{ fromTransportId, toTransport, overlapMs }`.
|
||||
- `interface PreemptiveSelectorConfig` — `{ overlapMs?, minScoreGap? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `evaluate` re-derives the degradation flag from the analyzer's most recent two samples instead of trusting a cached `degrading` boolean. This keeps the selector and the analyzer decoupled — callers may have recorded samples since the last `record` return value was inspected.
|
||||
- `minScoreGap` is critical to prevent thrash: without it, two transports with scores 71 and 70 would swap on every sample. Default `10` matches the hysteresis used by the `TransportHealthMonitor`.
|
||||
- The overlap window is the manager's responsibility to honour — the selector only reports the recommended duration. This separation lets the dispatcher own timer management (which it already does for retries) instead of forking timers across two modules.
|
||||
- The first call to `evaluate` for a fresh transport returns `null` because the analyzer has < 2 samples and cannot compute a delta. Tests pin this boundary explicitly.
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { QualityTrendAnalyzer } from "../analyzer/quality-trend-analyzer.js";
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import type { TransportPool } from "../pool/transport-pool.js";
|
||||
import type { TransportLike } from "../types/transport.types.js";
|
||||
|
||||
/**
|
||||
* Hand-off snapshot returned by `evaluate(...)` describing how the
|
||||
* caller should transition from a degrading transport to its
|
||||
* replacement. The hand-off establishes the new transport BEFORE the
|
||||
* old is torn down, with a configurable overlap window.
|
||||
*/
|
||||
export interface PreemptiveHandoff {
|
||||
readonly fromTransportId: string;
|
||||
readonly toTransport: TransportLike;
|
||||
readonly overlapMs: number;
|
||||
}
|
||||
|
||||
export interface PreemptiveSelectorConfig {
|
||||
/** Overlap window kept open during the hand-off. Defaults to 750 ms. */
|
||||
readonly overlapMs?: number;
|
||||
/** Minimum score gap required between current and replacement. Defaults to 10. */
|
||||
readonly minScoreGap?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a transport should be replaced *before* it actually
|
||||
* fails. Inputs come from the trend analyzer (slope) and the pool
|
||||
* (live health). Output describes a smooth hand-off — establish, then
|
||||
* tear down with overlap.
|
||||
*/
|
||||
export class PreemptiveSelector {
|
||||
private readonly pool: TransportPool;
|
||||
private readonly analyzer: QualityTrendAnalyzer;
|
||||
private readonly overlapMs: number;
|
||||
private readonly minScoreGap: number;
|
||||
|
||||
public constructor(
|
||||
pool: TransportPool,
|
||||
analyzer: QualityTrendAnalyzer,
|
||||
config: PreemptiveSelectorConfig = {}
|
||||
) {
|
||||
this.pool = pool;
|
||||
this.analyzer = analyzer;
|
||||
this.overlapMs = config.overlapMs ?? 750;
|
||||
this.minScoreGap = config.minScoreGap ?? 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the current transport's trend; if it is degrading and a
|
||||
* better candidate exists in the pool, return a hand-off plan.
|
||||
* Returns `null` when no swap is justified — leaving the existing
|
||||
* transport in place is the default safe action.
|
||||
*/
|
||||
public evaluate(currentTransportId: string): PreemptiveHandoff | null {
|
||||
const currentEntry = this.pool.get(currentTransportId);
|
||||
const trend = this.analyzer.record;
|
||||
// Reading the most recent trend is done by callers via the
|
||||
// analyzer; for the evaluator we re-derive `degrading` from the
|
||||
// latest slope snapshot via window inspection.
|
||||
void trend;
|
||||
const window = this.analyzer.window(currentTransportId);
|
||||
const degrading = isDegrading(window);
|
||||
if (!degrading) return null;
|
||||
|
||||
const replacement = this.bestAlternative(currentTransportId);
|
||||
if (replacement === null) return null;
|
||||
if (replacement.score - currentEntry.health.score < this.minScoreGap) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fromTransportId: currentTransportId,
|
||||
toTransport: replacement.transport,
|
||||
overlapMs: this.overlapMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best non-current candidate currently registered. Throws
|
||||
* `NO_HEALTHY_TRANSPORT` when the pool is otherwise empty so callers
|
||||
* surface a real problem instead of silently doing nothing.
|
||||
*/
|
||||
public requireBestAlternative(currentTransportId: string): {
|
||||
transport: TransportLike;
|
||||
score: number;
|
||||
} {
|
||||
const candidate = this.bestAlternative(currentTransportId);
|
||||
if (candidate === null) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.NO_HEALTHY_TRANSPORT,
|
||||
"preemptive.no-alternative",
|
||||
{ context: { currentTransportId } }
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private bestAlternative(
|
||||
currentTransportId: string
|
||||
): { transport: TransportLike; score: number } | null {
|
||||
let best: { transport: TransportLike; score: number } | null = null;
|
||||
for (const entry of this.pool.listHealthy()) {
|
||||
if (entry.transport.id === currentTransportId) continue;
|
||||
if (!entry.transport.connected) continue;
|
||||
if (best === null || entry.health.score > best.score) {
|
||||
best = {
|
||||
transport: entry.transport,
|
||||
score: entry.health.score,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
|
||||
function isDegrading(window: {
|
||||
latency: ReadonlyArray<number>;
|
||||
loss: ReadonlyArray<number>;
|
||||
bandwidthBps: ReadonlyArray<number>;
|
||||
}): boolean {
|
||||
if (window.latency.length < 2) return false;
|
||||
// Aggregate trend: rising latency OR rising loss OR falling bandwidth
|
||||
// over the last sample pair.
|
||||
const latencyDelta =
|
||||
window.latency[window.latency.length - 1]! -
|
||||
window.latency[window.latency.length - 2]!;
|
||||
const lossDelta =
|
||||
window.loss[window.loss.length - 1]! -
|
||||
window.loss[window.loss.length - 2]!;
|
||||
const bandwidthDelta =
|
||||
window.bandwidthBps[window.bandwidthBps.length - 1]! -
|
||||
window.bandwidthBps[window.bandwidthBps.length - 2]!;
|
||||
return latencyDelta > 0 || lossDelta > 0 || bandwidthDelta < 0;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { QualityTrendAnalyzer } from "../analyzer/quality-trend-analyzer.js";
|
||||
import { NETWORK_TM_CODES, type NetworkTmError } from "../error-codes.js";
|
||||
import { TransportPool } from "../pool/transport-pool.js";
|
||||
import type { TransportLike } from "../types/transport.types.js";
|
||||
import { PreemptiveSelector } from "./preemptive-selector.js";
|
||||
|
||||
const t = (
|
||||
id: string,
|
||||
capability: TransportLike["capability"]
|
||||
): TransportLike => ({ id, capability, connected: true });
|
||||
|
||||
const setHealth = (pool: TransportPool, id: string, score: number) => {
|
||||
pool.setHealth(id, {
|
||||
rttMs: 50,
|
||||
lossRate: 0,
|
||||
stability: 1,
|
||||
score,
|
||||
paused: false,
|
||||
});
|
||||
};
|
||||
|
||||
describe("NET-46 — PreemptiveSelector smooth handoff", () => {
|
||||
it("returns null when current transport has no trend yet", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
expect(selector.evaluate("wrtc")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no alternative exists even if degrading", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
analyzer.record("wrtc", {
|
||||
latency: 80,
|
||||
loss: 0,
|
||||
bandwidthBps: 1_000_000,
|
||||
});
|
||||
analyzer.record("wrtc", {
|
||||
latency: 200,
|
||||
loss: 0.05,
|
||||
bandwidthBps: 600_000,
|
||||
});
|
||||
setHealth(pool, "wrtc", 30);
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
expect(selector.evaluate("wrtc")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when alternative isn't materially better", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
pool.register(t("ws", "websocket"));
|
||||
setHealth(pool, "wrtc", 60);
|
||||
setHealth(pool, "ws", 65);
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
analyzer.record("wrtc", {
|
||||
latency: 80,
|
||||
loss: 0,
|
||||
bandwidthBps: 1_000_000,
|
||||
});
|
||||
analyzer.record("wrtc", {
|
||||
latency: 200,
|
||||
loss: 0.05,
|
||||
bandwidthBps: 600_000,
|
||||
});
|
||||
const selector = new PreemptiveSelector(pool, analyzer, {
|
||||
minScoreGap: 20,
|
||||
});
|
||||
expect(selector.evaluate("wrtc")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a smooth-handoff plan when degrading + better alternative exists", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
pool.register(t("ws", "websocket"));
|
||||
setHealth(pool, "wrtc", 30);
|
||||
setHealth(pool, "ws", 95);
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
analyzer.record("wrtc", {
|
||||
latency: 80,
|
||||
loss: 0,
|
||||
bandwidthBps: 1_000_000,
|
||||
});
|
||||
analyzer.record("wrtc", {
|
||||
latency: 250,
|
||||
loss: 0.1,
|
||||
bandwidthBps: 500_000,
|
||||
});
|
||||
const selector = new PreemptiveSelector(pool, analyzer, {
|
||||
overlapMs: 1_000,
|
||||
minScoreGap: 10,
|
||||
});
|
||||
const plan = selector.evaluate("wrtc");
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan?.fromTransportId).toBe("wrtc");
|
||||
expect(plan?.toTransport.id).toBe("ws");
|
||||
expect(plan?.overlapMs).toBe(1_000);
|
||||
});
|
||||
|
||||
it("requireBestAlternative throws NO_HEALTHY_TRANSPORT when alone", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
try {
|
||||
selector.requireBestAlternative("wrtc");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.NO_HEALTHY_TRANSPORT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("requireBestAlternative skips disconnected transports", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
pool.register({
|
||||
id: "ws-off",
|
||||
capability: "websocket",
|
||||
connected: false,
|
||||
});
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
try {
|
||||
selector.requireBestAlternative("wrtc");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.NO_HEALTHY_TRANSPORT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("requireBestAlternative returns the best of multiple healthy peers", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
pool.register(t("ws-a", "websocket"));
|
||||
pool.register(t("ws-b", "websocket"));
|
||||
setHealth(pool, "ws-a", 60);
|
||||
setHealth(pool, "ws-b", 95);
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
const best = selector.requireBestAlternative("wrtc");
|
||||
expect(best.transport.id).toBe("ws-b");
|
||||
expect(best.score).toBe(95);
|
||||
});
|
||||
|
||||
it("requireBestAlternative ignores lower-scoring entries seen later", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
pool.register(t("ws-best", "websocket"));
|
||||
pool.register(t("ws-worse", "websocket"));
|
||||
setHealth(pool, "ws-best", 95);
|
||||
setHealth(pool, "ws-worse", 30);
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
const best = selector.requireBestAlternative("wrtc");
|
||||
expect(best.transport.id).toBe("ws-best");
|
||||
});
|
||||
|
||||
it("evaluate returns null when latest two samples show recovery", () => {
|
||||
const pool = new TransportPool();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
pool.register(t("ws", "websocket"));
|
||||
setHealth(pool, "wrtc", 30);
|
||||
setHealth(pool, "ws", 95);
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
// Recovering: latency dropping, loss dropping, bandwidth climbing
|
||||
analyzer.record("wrtc", {
|
||||
latency: 250,
|
||||
loss: 0.1,
|
||||
bandwidthBps: 500_000,
|
||||
});
|
||||
analyzer.record("wrtc", {
|
||||
latency: 60,
|
||||
loss: 0,
|
||||
bandwidthBps: 1_000_000,
|
||||
});
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
expect(selector.evaluate("wrtc")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_TM_CODES, type NetworkTmError } from "../error-codes.js";
|
||||
import { TransportPool } from "../pool/transport-pool.js";
|
||||
import { PriorityRouter } from "../priority/priority-router.js";
|
||||
import type { TransportLike } from "../types/transport.types.js";
|
||||
import { TransportSelector } from "./transport-selector.js";
|
||||
|
||||
const t = (
|
||||
id: string,
|
||||
capability: TransportLike["capability"],
|
||||
overrides: Partial<TransportLike> = {}
|
||||
): TransportLike => ({
|
||||
id,
|
||||
capability,
|
||||
connected: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setHealth = (pool: TransportPool, id: string, score: number) => {
|
||||
pool.setHealth(id, {
|
||||
rttMs: 50,
|
||||
lossRate: 0,
|
||||
stability: 1,
|
||||
score,
|
||||
paused: false,
|
||||
});
|
||||
};
|
||||
|
||||
describe("NET-23/25 — TransportSelector priority + capability + health", () => {
|
||||
it("selects the highest-scoring matching transport for HIGH priority", () => {
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register(t("ws", "websocket"));
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
setHealth(pool, "ws", 80);
|
||||
setHealth(pool, "wrtc", 95);
|
||||
const selector = new TransportSelector(pool, router);
|
||||
const result = selector.select({
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["websocket", "webrtc-datachannel"],
|
||||
});
|
||||
expect(result.chosen[0]?.id).toBe("wrtc");
|
||||
expect(result.chosen.length).toBe(1);
|
||||
expect(result.redundant).toBe(false);
|
||||
});
|
||||
|
||||
it("CRITICAL fans out to >=2 transports for redundancy", () => {
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register(t("ws-a", "websocket"));
|
||||
pool.register(t("ws-b", "websocket"));
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
setHealth(pool, "ws-a", 90);
|
||||
setHealth(pool, "ws-b", 70);
|
||||
setHealth(pool, "wrtc", 95);
|
||||
const result = new TransportSelector(pool, router).select({
|
||||
priority: "CRITICAL",
|
||||
commonCapabilities: ["websocket", "webrtc-datachannel"],
|
||||
});
|
||||
expect(result.chosen.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.redundant).toBe(true);
|
||||
});
|
||||
|
||||
it("filters out paused / disconnected / excluded transports", () => {
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register(t("ws-paused", "websocket"));
|
||||
pool.register(t("ws-disco", "websocket", { connected: false }));
|
||||
pool.register(t("ws-excluded", "websocket"));
|
||||
pool.register(t("ws-good", "websocket"));
|
||||
setHealth(pool, "ws-paused", 90);
|
||||
pool.setHealth("ws-paused", {
|
||||
rttMs: 50,
|
||||
lossRate: 0,
|
||||
stability: 1,
|
||||
score: 90,
|
||||
paused: true,
|
||||
});
|
||||
setHealth(pool, "ws-excluded", 90);
|
||||
setHealth(pool, "ws-good", 80);
|
||||
const result = new TransportSelector(pool, router).select({
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["websocket"],
|
||||
excludeTransportIds: ["ws-excluded"],
|
||||
});
|
||||
expect(result.chosen.map((c) => c.id)).toEqual(["ws-good"]);
|
||||
});
|
||||
|
||||
it("throws NO_HEALTHY_TRANSPORT when no candidate matches", () => {
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register(t("ws", "websocket"));
|
||||
const selector = new TransportSelector(pool, router);
|
||||
try {
|
||||
selector.select({
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["bluetooth-le"],
|
||||
});
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkTmError).code).toBe(
|
||||
NETWORK_TM_CODES.NO_HEALTHY_TRANSPORT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects capability negotiation — non-overlapping capabilities are skipped", () => {
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register(t("wrtc", "webrtc-datachannel"));
|
||||
pool.register(t("ws", "websocket"));
|
||||
setHealth(pool, "wrtc", 95);
|
||||
setHealth(pool, "ws", 70);
|
||||
const selector = new TransportSelector(pool, router);
|
||||
const result = selector.select({
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["websocket"],
|
||||
});
|
||||
expect(result.chosen[0]?.id).toBe("ws");
|
||||
});
|
||||
|
||||
it("redundant: true forces fan-out even on single-transport tiers", () => {
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register(t("ws-a", "websocket"));
|
||||
pool.register(t("ws-b", "websocket"));
|
||||
setHealth(pool, "ws-a", 90);
|
||||
setHealth(pool, "ws-b", 80);
|
||||
const result = new TransportSelector(pool, router).select({
|
||||
priority: "NORMAL",
|
||||
commonCapabilities: ["websocket"],
|
||||
redundant: true,
|
||||
});
|
||||
// NORMAL maxTransports defaults to 1; redundant ask is clamped by
|
||||
// the per-tier ceiling — here we want at least 1 selection.
|
||||
expect(result.chosen.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("selectIds returns the same ids as select.chosen", () => {
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register(t("ws", "websocket"));
|
||||
setHealth(pool, "ws", 50);
|
||||
const selector = new TransportSelector(pool, router);
|
||||
const ids = selector.selectIds({
|
||||
priority: "NORMAL",
|
||||
commonCapabilities: ["websocket"],
|
||||
});
|
||||
expect(ids).toEqual(["ws"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
# `TransportSelector`
|
||||
|
||||
## Purpose
|
||||
|
||||
Translates a `(priority, commonCapabilities)` ask into a concrete, ordered list of transports drawn from the live pool. The selector is the single boundary that combines three independent inputs — the pool's per-transport health, the router's fan-out policy, and the negotiator's per-peer capability intersection — into a deterministic selection. Downstream, the dispatcher just sends to whatever the selector returns.
|
||||
|
||||
## Features
|
||||
|
||||
- Health-sorted (`score` desc) shortlist filtered to candidates that are connected, in the negotiated capability set, and not on the caller's exclusion list.
|
||||
- Honours the priority router's `minTransports`/`maxTransports` per tier and lets callers force redundant fan-out via `redundant: true`.
|
||||
- `NO_HEALTHY_TRANSPORT` is raised when the candidate set is empty so callers do not silently swallow a partition.
|
||||
- `candidates(...)` exposed for tests and for the round-robin balancer that wraps the selector.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
PriorityRouter,
|
||||
TransportSelector,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
|
||||
const selector = new TransportSelector(pool, router);
|
||||
const result = selector.select({
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["webrtc-datachannel", "websocket"],
|
||||
});
|
||||
result.chosen[0].id; // highest-scoring of the two
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
PriorityRouter,
|
||||
TransportSelector,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register({ id: "ws-a", capability: "websocket", connected: true });
|
||||
pool.register({ id: "ws-b", capability: "websocket", connected: true });
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
pool.setHealth("ws-a", { rttMs: 30, lossRate: 0, stability: 1, score: 100, paused: false });
|
||||
pool.setHealth("ws-b", { rttMs: 80, lossRate: 0.01, stability: 0.9, score: 70, paused: false });
|
||||
pool.setHealth("wrtc", { rttMs: 60, lossRate: 0, stability: 1, score: 90, paused: false });
|
||||
|
||||
const selector = new TransportSelector(pool, router);
|
||||
|
||||
// CRITICAL fans out across the top two transports for redundancy
|
||||
const critical = selector.select({
|
||||
priority: "CRITICAL",
|
||||
commonCapabilities: ["webrtc-datachannel", "websocket"],
|
||||
});
|
||||
critical.chosen.length; // 2
|
||||
critical.redundant; // true
|
||||
|
||||
// Force redundant fan-out on a normally single-transport tier
|
||||
const forced = selector.select({
|
||||
priority: "NORMAL",
|
||||
commonCapabilities: ["websocket"],
|
||||
redundant: true,
|
||||
});
|
||||
forced.chosen.length; // 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransportSelector(pool, router)`
|
||||
- `select(config: SelectorConfig): SelectionResult` — throws `NO_HEALTHY_TRANSPORT` when no candidate matches.
|
||||
- `candidates(priority, commonCapabilities, excludeTransportIds): ReadonlyArray<TransportEntry>` — health-sorted list before fan-out cap is applied.
|
||||
- `selectIds(config): ReadonlyArray<string>` — convenience that returns just the chosen ids.
|
||||
- `interface SelectorConfig` — `{ priority, commonCapabilities, excludeTransportIds?, redundant? }`.
|
||||
- `interface SelectionResult` — `{ priority, chosen, redundant }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The candidate filter uses `Set` for both the capability allowlist and the exclusion list (`O(n)` filter where `n` = pool size). Both sets are short for typical mesh sizes (≤ 20 transports per node).
|
||||
- Sorting is stable in V8/SpiderMonkey for arrays smaller than ~10 items, so transports with identical scores keep their pool insertion order — this means the manager picks the same redundant pair across calls for a given health snapshot, which is what tests rely on.
|
||||
- The `priority` parameter to `candidates` is currently informational. It stays in the signature so deployment-specific overrides (e.g. excluding `relay-via-host` from CRITICAL) can subclass the selector without API churn.
|
||||
- `redundant: true` clamps `minTransports` to `>= 2` rather than overriding `maxTransports` because callers asking for redundancy still want to respect the configured ceiling.
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NETWORK_TM_CODES, NetworkTmError } from "../error-codes.js";
|
||||
import type { TransportPool } from "../pool/transport-pool.js";
|
||||
import type { PriorityRouter } from "../priority/priority-router.js";
|
||||
import type { MessagePriority } from "../types/priority.types.js";
|
||||
import type {
|
||||
SelectionResult,
|
||||
SelectorConfig,
|
||||
} from "../types/selector.types.js";
|
||||
import type {
|
||||
TransportEntry,
|
||||
TransportLike,
|
||||
} from "../types/transport.types.js";
|
||||
|
||||
/**
|
||||
* Selects which registered transports should carry an outbound
|
||||
* message. The selector pulls candidates from the pool, filters by the
|
||||
* negotiated capability intersection and the priority router's
|
||||
* fan-out config, then sorts by health score and returns up to
|
||||
* `maxTransports` of the best.
|
||||
*/
|
||||
export class TransportSelector {
|
||||
private readonly pool: TransportPool;
|
||||
private readonly router: PriorityRouter;
|
||||
|
||||
public constructor(pool: TransportPool, router: PriorityRouter) {
|
||||
this.pool = pool;
|
||||
this.router = router;
|
||||
}
|
||||
|
||||
public select(config: SelectorConfig): SelectionResult {
|
||||
const tier = this.router.configFor(config.priority);
|
||||
const candidates = this.candidates(
|
||||
config.priority,
|
||||
config.commonCapabilities,
|
||||
config.excludeTransportIds ?? []
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.NO_HEALTHY_TRANSPORT,
|
||||
"selector.empty",
|
||||
{
|
||||
context: {
|
||||
priority: config.priority,
|
||||
commonCapabilities: [...config.commonCapabilities],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const requested =
|
||||
config.redundant === true
|
||||
? Math.max(tier.minTransports, 2)
|
||||
: tier.minTransports;
|
||||
const cap = Math.min(tier.maxTransports, candidates.length);
|
||||
const desired = Math.min(Math.max(requested, 1), cap);
|
||||
const chosen = candidates
|
||||
.slice(0, desired)
|
||||
.map((entry) => entry.transport);
|
||||
return {
|
||||
priority: config.priority,
|
||||
chosen,
|
||||
redundant: chosen.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtered, score-sorted list of candidate entries for a tier.
|
||||
* Exposed for tests and for the round-robin balancer that sits one
|
||||
* level up.
|
||||
*/
|
||||
public candidates(
|
||||
priority: MessagePriority,
|
||||
commonCapabilities: ReadonlyArray<string>,
|
||||
excludeTransportIds: ReadonlyArray<string>
|
||||
): ReadonlyArray<TransportEntry> {
|
||||
// `priority` is currently informational — kept in the signature so
|
||||
// overrides MAY apply different filters per tier without breaking
|
||||
// callers.
|
||||
void priority;
|
||||
const allow = new Set(commonCapabilities);
|
||||
const exclude = new Set(excludeTransportIds);
|
||||
const out: TransportEntry[] = [];
|
||||
for (const entry of this.pool.listHealthy()) {
|
||||
if (exclude.has(entry.transport.id)) continue;
|
||||
if (!allow.has(entry.transport.capability)) continue;
|
||||
if (!entry.transport.connected) continue;
|
||||
out.push(entry);
|
||||
}
|
||||
out.sort((a, b) => b.health.score - a.health.score);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convenience helper for tests / dispatchers that just need the IDs. */
|
||||
public selectIds(config: SelectorConfig): ReadonlyArray<string> {
|
||||
return this.select(config).chosen.map((t: TransportLike) => t.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Priority types — `@suaveplan/network-plugin-transport-manager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared types for the five-tier priority router (`NET-25`). The router uses a closed `MessagePriority` union so consumers cannot accidentally pass arbitrary strings, and `PriorityConfig` carries the per-tier knobs (`minTransports`, `maxTransports`, `dedup`) the router exposes for overrides.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed union of five tiers in spec order: `CRITICAL`, `HIGH`, `NORMAL`, `LOW`, `BULK`.
|
||||
- `PriorityConfig` describes everything a tier needs to operationalise: how many transports to fan out to, how many at most, and whether duplicate arrivals should be deduplicated on the receiver side.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { MessagePriority } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const tier: MessagePriority = "CRITICAL";
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MessagePriority,
|
||||
PriorityConfig,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const overrides: Partial<Record<MessagePriority, PriorityConfig>> = {
|
||||
CRITICAL: { minTransports: 2, maxTransports: 4, dedup: true },
|
||||
BULK: { minTransports: 1, maxTransports: 1, dedup: false },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type MessagePriority` — `"CRITICAL" | "HIGH" | "NORMAL" | "LOW" | "BULK"`.
|
||||
- `interface PriorityConfig` — `{ minTransports: number; maxTransports: number; dedup: boolean }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The union order matches the priority order used by `PriorityRouter` and `SessionPersistenceBuffer`: CRITICAL is "highest", BULK is "lowest". Several modules use this ordering to walk priorities, so reordering the union would be a breaking change at runtime even though the type system would accept it.
|
||||
- `dedup: true` is intentionally opt-in per tier — most tiers do not need it because they fan out to a single transport, so duplicate arrivals are impossible by construction.
|
||||
- Adding a tier (e.g. `REALTIME`) would require coordinated changes to the router default config table, the persistence buffer's eviction policy, the bandwidth conservator's `shouldCoalesce` switch, and every spec-conformance test that pins the closed set. It is not a drop-in extension.
|
||||
- The type lives in a dedicated file so cross-module type-only imports (`import type { MessagePriority } from "..."`) do not pull in any runtime code.
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Five priority tiers governing transport selection and redundancy
|
||||
* behaviour. Defined by the transport-manager spec (`NET-25`).
|
||||
*/
|
||||
export type MessagePriority = "CRITICAL" | "HIGH" | "NORMAL" | "LOW" | "BULK";
|
||||
|
||||
/**
|
||||
* Per-tier configuration. Values in this struct govern how many
|
||||
* transports a single `dispatch()` call selects and whether duplicate
|
||||
* arrivals are deduplicated.
|
||||
*/
|
||||
export interface PriorityConfig {
|
||||
/** Minimum number of transports required for the tier. */
|
||||
readonly minTransports: number;
|
||||
/** Maximum number of transports the router will fan out to. */
|
||||
readonly maxTransports: number;
|
||||
/** True when the router should track in-flight ids for dedup. */
|
||||
readonly dedup: boolean;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Selector types — `@suaveplan/network-plugin-transport-manager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Input/output shapes for `TransportSelector.select(...)`. The selector translates a `(priority, capabilities)` ask into a concrete transport list, so the contract belongs in a dedicated types file that both the selector and its callers (priority router, plugin facade, custom dispatchers) can import without depending on the selector implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `SelectorConfig` — every input the selector needs in a single struct: priority, the capability intersection from the negotiator, an optional exclude list (for "the path I just dialed failed, give me another"), and an optional `redundant: true` override that forces fan-out on tiers that would otherwise pick a single transport.
|
||||
- `SelectionResult` — the chosen transports in send order plus a `redundant` boolean callers can switch on to enable receiver-side dedup.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SelectionResult,
|
||||
SelectorConfig,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const ask: SelectorConfig = {
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["webrtc-datachannel", "websocket"],
|
||||
};
|
||||
|
||||
declare const result: SelectionResult;
|
||||
result.chosen.forEach((t) => console.log(t.id));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SelectorConfig,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
// Force fan-out even on a tier that the router treats as single-transport
|
||||
const forced: SelectorConfig = {
|
||||
priority: "NORMAL",
|
||||
commonCapabilities: ["websocket", "http-fetch"],
|
||||
excludeTransportIds: ["ws-flapping-1"],
|
||||
redundant: true,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SelectorConfig` — `{ priority, commonCapabilities, excludeTransportIds?, redundant? }`.
|
||||
- `interface SelectionResult` — `{ priority, chosen, redundant }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `commonCapabilities` is `ReadonlyArray<TransportCapability>` rather than `Set<TransportCapability>` so callers can preserve a deterministic preference order and pass the result of `CapabilityNegotiator.commonWith(peerId)` directly.
|
||||
- `excludeTransportIds` is the boundary the dispatcher uses to communicate "this path just failed, do not pick it again on retry". Including it in the config struct rather than the router state keeps retries observable and stateless.
|
||||
- `SelectionResult.priority` is echoed back so callers logging or instrumenting the result do not need to remember which priority they asked for — important when the selector is wrapped in a generic dispatcher pipeline.
|
||||
- These types live separately from the runtime selector module so cross-file type imports stay zero-cost at runtime.
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MessagePriority } from "./priority.types.js";
|
||||
import type { TransportCapability, TransportLike } from "./transport.types.js";
|
||||
|
||||
/**
|
||||
* Selector inputs evaluated against the pool to pick the best transport
|
||||
* for an outbound message.
|
||||
*/
|
||||
export interface SelectorConfig {
|
||||
readonly priority: MessagePriority;
|
||||
/** Capabilities both peers support — intersection from the negotiator. */
|
||||
readonly commonCapabilities: ReadonlyArray<TransportCapability>;
|
||||
/** Transports the caller would like to exclude (e.g. dead path). */
|
||||
readonly excludeTransportIds?: ReadonlyArray<string>;
|
||||
/** True when the caller wants the router to honour redundancy rules. */
|
||||
readonly redundant?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a selection — the transports the router chose, in send
|
||||
* order, plus the priority used.
|
||||
*/
|
||||
export interface SelectionResult {
|
||||
readonly priority: MessagePriority;
|
||||
readonly chosen: ReadonlyArray<TransportLike>;
|
||||
/** True when the selector returned more than one transport for redundancy. */
|
||||
readonly redundant: boolean;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Transport types — `@suaveplan/network-plugin-transport-manager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared structural types for transport-manager subsystems. These types encode the contract between the manager and the underlying transports (`network-plugin-webrtc`, `-websocket`, `-http`, …) without forcing the manager to depend on any concrete implementation. Concrete transports satisfy `TransportLike` structurally, so the manager can be tested with synthetic transports and the production code path matches the test path.
|
||||
|
||||
## Features
|
||||
|
||||
- `TransportCapability` — closed union of the seven capability bitmap values exchanged at peer handshake.
|
||||
- `TransportHealth` / `TransportHealthSample` — input/output shapes for the health monitor pipeline.
|
||||
- `TransportLike` — minimal structural contract a registered transport must satisfy. Includes the optional `bandwidthHintBps` and `highPower` fields the bandwidth conservator and battery scheduler consume when deciding whether to coalesce or rotate.
|
||||
- `TransportEntry` — pool-stored tuple of `{ transport, health }` so consumers get both in a single lookup.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TransportCapability,
|
||||
TransportLike,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const cap: TransportCapability = "websocket";
|
||||
const t: TransportLike = {
|
||||
id: "ws-1",
|
||||
capability: cap,
|
||||
connected: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TransportEntry,
|
||||
TransportHealth,
|
||||
TransportLike,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const transport: TransportLike = {
|
||||
id: "wrtc",
|
||||
capability: "webrtc-datachannel",
|
||||
connected: true,
|
||||
bandwidthHintBps: 5_000_000,
|
||||
highPower: true,
|
||||
};
|
||||
|
||||
const health: TransportHealth = {
|
||||
rttMs: 60,
|
||||
lossRate: 0.01,
|
||||
stability: 0.95,
|
||||
score: 92,
|
||||
paused: false,
|
||||
};
|
||||
|
||||
const entry: TransportEntry = { transport, health };
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type TransportCapability` — `"webrtc-datachannel" | "websocket" | "http-fetch" | "http-sse" | "bluetooth-le" | "local-network-mdns" | "relay-via-host"`.
|
||||
- `interface TransportHealth` — `{ rttMs, lossRate, stability, score, paused }` — every field is `readonly` because the pool stores frozen snapshots.
|
||||
- `interface TransportHealthSample` — `{ rttMs, lossRate, dropped? }`.
|
||||
- `interface TransportLike` — `{ id, capability, connected, bandwidthHintBps?, highPower? }`.
|
||||
- `interface TransportEntry` — `{ transport, health }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The capability list is intentionally closed: extending it requires bumping the wire protocol because peers advertise capability bitmaps to each other. New capabilities should land via OpenSpec change so that the negotiator and selector can validate them across versions.
|
||||
- `TransportHealth.score` is documented as `0..100` and `lossRate` / `stability` as `[0, 1]`. The pool clamps both at the boundary; consumers that build a `TransportHealth` directly are expected to honour the same ranges.
|
||||
- `highPower` is opt-in (`undefined` means "not specified"). Battery-aware scheduling treats unspecified as low-power — a safer default than over-pessimistic rotation.
|
||||
- `TransportEntry` is treated as immutable: `TransportPool.setHealth` replaces the entry rather than mutating it so consumers can hold a reference to the prior snapshot safely.
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Capability bitmap entries exchanged during peer handshake. Both peers
|
||||
* MUST agree on a transport before any non-trivial traffic is sent over
|
||||
* it.
|
||||
*/
|
||||
export type TransportCapability =
|
||||
| "webrtc-datachannel"
|
||||
| "websocket"
|
||||
| "http-fetch"
|
||||
| "http-sse"
|
||||
| "bluetooth-le"
|
||||
| "local-network-mdns"
|
||||
| "relay-via-host";
|
||||
|
||||
/**
|
||||
* Health snapshot for a single transport. Aggregated by
|
||||
* `TransportHealthMonitor` from `recordSample` invocations.
|
||||
*/
|
||||
export interface TransportHealth {
|
||||
/** RTT in ms — exponentially-weighted moving average. */
|
||||
readonly rttMs: number;
|
||||
/** Packet loss rate in [0, 1] over the recent window. */
|
||||
readonly lossRate: number;
|
||||
/** Stability — how often the connection has dropped recently in [0, 1]. */
|
||||
readonly stability: number;
|
||||
/** Computed score in [0, 100]. Higher is better. */
|
||||
readonly score: number;
|
||||
/** True when the monitor has paused this transport. */
|
||||
readonly paused: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single sample reported into the health monitor.
|
||||
*/
|
||||
export interface TransportHealthSample {
|
||||
readonly rttMs: number;
|
||||
readonly lossRate: number;
|
||||
/** True if the most recent send/recv attempt indicated a drop. */
|
||||
readonly dropped?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal transport contract used by `TransportPool`. Concrete transport
|
||||
* implementations (`network-plugin-webrtc`, `-websocket`, …) MUST satisfy
|
||||
* this structural type without being imported from this package.
|
||||
*/
|
||||
export interface TransportLike {
|
||||
readonly id: string;
|
||||
readonly capability: TransportCapability;
|
||||
/** True if the transport is currently dialed to at least one peer. */
|
||||
readonly connected: boolean;
|
||||
/** Approximate per-second bytes the transport can carry. */
|
||||
readonly bandwidthHintBps?: number;
|
||||
/** True if the underlying medium is power-hungry (e.g. WebRTC ICE). */
|
||||
readonly highPower?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool entry tracking a registered transport plus its derived health.
|
||||
*/
|
||||
export interface TransportEntry {
|
||||
readonly transport: TransportLike;
|
||||
readonly health: TransportHealth;
|
||||
}
|
||||
Reference in New Issue
Block a user