Initial Commit
This commit is contained in:
22
packages/networking/network/network-plugin-qos/README.md
Normal file
22
packages/networking/network/network-plugin-qos/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @suaveplan/network-plugin-qos - Bandwidth management and QoS for the mesh: bandwidth-quota enforcer, message-priority classifier, QoS router, congestion monitor
|
||||
|
||||
## Summary
|
||||
|
||||
Bandwidth management and quality-of-service for the mesh: bandwidth-quota enforcer, message-priority classifier, QoS router (bypass / drop / queue / defer), congestion monitor with sustained-period alerts.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [classifier](./src/classifier/message-priority-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [message-priority-classifier](./src/classifier/message-priority-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [bandwidth-quota-enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [congestion-monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [qos-router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-qos plugin. One code covers the wire surface: `QUOTA_EXCEEDED` is…
|
||||
<!-- end:auto-docs --> - [message-priority-classifier](./src/classifier/message-priority-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [bandwidth-quota-enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [congestion-monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [qos-router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-qos plugin. One code covers the wire surface: `QUOTA_EXCEEDED` is…
|
||||
- [message-priority-classifier](./src/classifier/message-priority-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [bandwidth-quota-enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [congestion-monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [qos-router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-qos plugin. One code covers the wire surface: `QUOTA_EXCEEDED` is…
|
||||
- [message-priority-classifier](./src/classifier/message-priority-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [bandwidth-quota-enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [congestion-monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [qos-router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-qos plugin. One code covers the wire surface: `QUOTA_EXCEEDED` is…
|
||||
- [message-priority-classifier](./src/classifier/message-priority-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bandwidth-quota-enforcer](./src/enforcer/bandwidth-quota-enforcer.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [congestion-monitor](./src/monitor/congestion-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_ - [qos-router](./src/router/qos-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-qos plugin. One code covers the wire surface: `QUOTA_EXCEEDED` is…
|
||||
49
packages/networking/network/network-plugin-qos/package.json
Normal file
49
packages/networking/network/network-plugin-qos/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-qos",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Bandwidth management and QoS for the mesh: bandwidth-quota enforcer, message-priority classifier, QoS router, congestion monitor",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/network": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MessagePriorityClassifier } from "./message-priority-classifier.js";
|
||||
|
||||
function envelope(
|
||||
type: string,
|
||||
headers: Record<string, string | number | boolean> = {}
|
||||
): MessageEnvelope {
|
||||
return {
|
||||
id: "x",
|
||||
type,
|
||||
senderId: "self",
|
||||
payload: null,
|
||||
timestamp: 0,
|
||||
headers,
|
||||
ttl: 8,
|
||||
visitedNodes: [],
|
||||
} as MessageEnvelope;
|
||||
}
|
||||
|
||||
describe("MessagePriorityClassifier (default rules)", () => {
|
||||
it("classifies election: as URGENT", () => {
|
||||
const c = new MessagePriorityClassifier();
|
||||
expect(c.classify(envelope("election:start"), 100)).toBe("URGENT");
|
||||
});
|
||||
|
||||
it("classifies registry: as CONTROL", () => {
|
||||
const c = new MessagePriorityClassifier();
|
||||
expect(c.classify(envelope("registry:add"), 100)).toBe("CONTROL");
|
||||
});
|
||||
|
||||
it("classifies audit: as BULK", () => {
|
||||
const c = new MessagePriorityClassifier();
|
||||
expect(c.classify(envelope("audit:append"), 100)).toBe("BULK");
|
||||
});
|
||||
|
||||
it("falls through to DATA by default", () => {
|
||||
const c = new MessagePriorityClassifier();
|
||||
expect(c.classify(envelope("game:move"), 100)).toBe("DATA");
|
||||
});
|
||||
|
||||
it("uses a custom defaultClass", () => {
|
||||
const c = new MessagePriorityClassifier({
|
||||
rules: [],
|
||||
defaultClass: "BULK",
|
||||
});
|
||||
expect(c.classify(envelope("anything"), 100)).toBe("BULK");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessagePriorityClassifier (custom rules)", () => {
|
||||
it("matches priorityHeader presence", () => {
|
||||
const c = new MessagePriorityClassifier({
|
||||
rules: [
|
||||
{
|
||||
priorityHeader: "x-urgent",
|
||||
classify: "URGENT",
|
||||
},
|
||||
{ typeMatches: /^game:/, classify: "DATA" },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
c.classify(envelope("game:tick", { "x-urgent": "1" }), 100)
|
||||
).toBe("URGENT");
|
||||
expect(c.classify(envelope("game:tick"), 100)).toBe("DATA");
|
||||
});
|
||||
|
||||
it("respects maxBytes ceilings", () => {
|
||||
const c = new MessagePriorityClassifier({
|
||||
rules: [
|
||||
{
|
||||
typeMatches: /^chat:/,
|
||||
maxBytes: 100,
|
||||
classify: "CONTROL",
|
||||
},
|
||||
{ typeMatches: /^chat:/, classify: "BULK" },
|
||||
],
|
||||
});
|
||||
expect(c.classify(envelope("chat:msg"), 50)).toBe("CONTROL");
|
||||
expect(c.classify(envelope("chat:msg"), 200)).toBe("BULK");
|
||||
});
|
||||
|
||||
it("first match wins; later rules ignored", () => {
|
||||
const c = new MessagePriorityClassifier({
|
||||
rules: [
|
||||
{ typeMatches: /^x:/, classify: "URGENT" },
|
||||
{ typeMatches: /^x:/, classify: "BULK" },
|
||||
],
|
||||
});
|
||||
expect(c.classify(envelope("x:hi"), 100)).toBe("URGENT");
|
||||
});
|
||||
|
||||
it("type mismatch skips the rule", () => {
|
||||
const c = new MessagePriorityClassifier({
|
||||
rules: [{ typeMatches: /^foo/, classify: "URGENT" }],
|
||||
defaultClass: "DATA",
|
||||
});
|
||||
expect(c.classify(envelope("bar:nope"), 100)).toBe("DATA");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
# MessagePriorityClassifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Walks an envelope through an ordered rule set and returns the first matching `QoSClass`. The result is what `QoSRouter` uses to decide whether to bypass, drop, queue, or defer. Rules are configurable so meshes with different traffic mixes can tune classification without touching the router.
|
||||
|
||||
## Features
|
||||
|
||||
- Four QoS classes: `URGENT`, `CONTROL`, `DATA`, `BULK`.
|
||||
- Each rule can match on `typeMatches` regex, presence of `priorityHeader`, and/or `maxBytes` ceiling.
|
||||
- Default rule set classifies `election:` / `failover:` / `heartbeat:` as `URGENT`, `registry:` / `topology:` / `ack:` as `CONTROL`, `audit:` / `backup:` / `sync:` as `BULK`, everything else as `DATA`.
|
||||
- Callers can replace the rules entirely or just the default class.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MessagePriorityClassifier } from "@suaveplan/network-plugin-qos";
|
||||
|
||||
const classifier = new MessagePriorityClassifier();
|
||||
const cls = classifier.classify(envelope, payloadBytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MessagePriorityClassifier,
|
||||
type ClassificationRule,
|
||||
} from "@suaveplan/network-plugin-qos";
|
||||
|
||||
const rules: ClassificationRule[] = [
|
||||
{
|
||||
typeMatches: /^game:/,
|
||||
priorityHeader: "x-urgent",
|
||||
classify: "URGENT",
|
||||
},
|
||||
{ typeMatches: /^chat:/, maxBytes: 1024, classify: "CONTROL" },
|
||||
{ typeMatches: /^chat:/, classify: "DATA" },
|
||||
{ typeMatches: /^stream:/, classify: "BULK" },
|
||||
];
|
||||
|
||||
const classifier = new MessagePriorityClassifier({
|
||||
rules,
|
||||
defaultClass: "DATA",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type QoSClass = "URGENT" | "CONTROL" | "DATA" | "BULK"`.
|
||||
- `interface ClassificationRule { typeMatches?, priorityHeader?, maxBytes?, classify }`.
|
||||
- `interface MessagePriorityClassifierConfig { rules?, defaultClass? }`.
|
||||
- `classify(envelope, sizeBytes): QoSClass`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Rules are evaluated in registration order with a first-match-wins semantic, which means more specific rules MUST come before catch-alls. We resist the temptation to score-and-rank rules because deterministic ordering is easier to reason about for operators tuning policy. The `priorityHeader` check tests for _presence_ of the header rather than a specific value — applications that need value-conditional priority should encode that via custom `typeMatches` patterns. The default rule set covers the common operational mesh traffic shapes; consumers with non-standard topic prefixes should pass their own `rules` array rather than mutating the defaults at runtime.
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
|
||||
/**
|
||||
* QoS class — drives router behaviour.
|
||||
*/
|
||||
export type QoSClass = "URGENT" | "CONTROL" | "DATA" | "BULK";
|
||||
|
||||
/**
|
||||
* One classification rule.
|
||||
*/
|
||||
export interface ClassificationRule {
|
||||
readonly typeMatches?: RegExp;
|
||||
readonly priorityHeader?: string;
|
||||
readonly maxBytes?: number;
|
||||
readonly classify: QoSClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `MessagePriorityClassifier`.
|
||||
*/
|
||||
export interface MessagePriorityClassifierConfig {
|
||||
readonly rules?: readonly ClassificationRule[];
|
||||
readonly defaultClass?: QoSClass;
|
||||
}
|
||||
|
||||
const DEFAULT_RULES: readonly ClassificationRule[] = [
|
||||
{
|
||||
typeMatches: /^(election|failover|heartbeat):/,
|
||||
classify: "URGENT",
|
||||
},
|
||||
{ typeMatches: /^(registry|topology|ack):/, classify: "CONTROL" },
|
||||
{ typeMatches: /^(audit|backup|sync):/, classify: "BULK" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Walks an envelope through the rule set in order and returns the first
|
||||
* matching `QoSClass`. If no rule matches, returns the configured
|
||||
* `defaultClass` (default `DATA`).
|
||||
*/
|
||||
export class MessagePriorityClassifier {
|
||||
private readonly rules: readonly ClassificationRule[];
|
||||
private readonly defaultClass: QoSClass;
|
||||
|
||||
public constructor(config: MessagePriorityClassifierConfig = {}) {
|
||||
this.rules = config.rules ?? DEFAULT_RULES;
|
||||
this.defaultClass = config.defaultClass ?? "DATA";
|
||||
}
|
||||
|
||||
public classify(envelope: MessageEnvelope, sizeBytes: number): QoSClass {
|
||||
for (const rule of this.rules) {
|
||||
if (
|
||||
rule.typeMatches !== undefined &&
|
||||
!rule.typeMatches.test(envelope.type)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (rule.priorityHeader !== undefined) {
|
||||
const v = envelope.headers[rule.priorityHeader];
|
||||
if (v === undefined) continue;
|
||||
}
|
||||
if (rule.maxBytes !== undefined && sizeBytes > rule.maxBytes) {
|
||||
continue;
|
||||
}
|
||||
return rule.classify;
|
||||
}
|
||||
return this.defaultClass;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# BandwidthQuotaEnforcer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Sliding-window bandwidth-quota enforcer for the QoS plugin. Each peer or path id has its own rolling history; `record(id, bytes)` is the gated write that throws `QUOTA_EXCEEDED` if the new total would breach the configured per-id quota inside the current window. Centralising this in one class means transport-manager, relay, and backbone pipelines all consult the same source of truth for "is this peer over its allocation?".
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `windowMs` (default 1 s) and `defaultQuotaBytes` (default 1 MiB/s).
|
||||
- Per-id quota override via `setQuota(id, bytes)`.
|
||||
- `usedBytes(id)` exposes current usage for observability.
|
||||
- `assertWithin(id, bytes)` is the dry-run check; `record` does the gated write atomically.
|
||||
- Pluggable `clock` for deterministic tests.
|
||||
- `clear(id?)` for forgetting one or all id histories.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BandwidthQuotaEnforcer } from "@suaveplan/network-plugin-qos";
|
||||
|
||||
const enforcer = new BandwidthQuotaEnforcer({
|
||||
windowMs: 1000,
|
||||
defaultQuotaBytes: 1_048_576,
|
||||
});
|
||||
|
||||
enforcer.record("alice", 500_000);
|
||||
enforcer.record("alice", 400_000);
|
||||
// next 500_000 would exceed the 1 MiB quota → throws
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
BandwidthQuotaEnforcer,
|
||||
QOS_CODES,
|
||||
QosError,
|
||||
} from "@suaveplan/network-plugin-qos";
|
||||
|
||||
let now = 0;
|
||||
const enforcer = new BandwidthQuotaEnforcer({
|
||||
windowMs: 5_000,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
enforcer.setQuota("alice", 200_000);
|
||||
enforcer.record("alice", 100_000);
|
||||
|
||||
try {
|
||||
enforcer.record("alice", 200_000);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof QosError &&
|
||||
err.code === QOS_CODES.QUOTA_EXCEEDED
|
||||
) {
|
||||
await wait(now + 5_000);
|
||||
// window has rolled, quota is fresh
|
||||
now += 5_001;
|
||||
enforcer.record("alice", 200_000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface BandwidthQuotaEnforcerConfig { windowMs?, defaultQuotaBytes?, clock? }`.
|
||||
- `setQuota(id, bytes)` / `getQuota(id): number`.
|
||||
- `usedBytes(id): number`.
|
||||
- `assertWithin(id, bytes)` — throws `QUOTA_EXCEEDED` on breach.
|
||||
- `record(id, bytes)` — gated write.
|
||||
- `clear(id?)` — forget one or all id histories.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The history is lazily pruned: `collect(id)` filters out entries older than the window every time it's called, so memory only ever grows up to the active history of recent writes per id. We accept this O(n) per call because n is bounded by the per-window write rate, and the alternative (background cleanup) requires owning a timer the consumer must remember to dispose. `record` calls `assertWithin` first so the check and write are always atomic from the caller's perspective.
|
||||
@@ -0,0 +1,86 @@
|
||||
import { QOS_CODES, QosError } from "../error-codes.js";
|
||||
|
||||
/**
|
||||
* Configuration for `BandwidthQuotaEnforcer`.
|
||||
*/
|
||||
export interface BandwidthQuotaEnforcerConfig {
|
||||
readonly windowMs?: number;
|
||||
readonly defaultQuotaBytes?: number;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
interface UsageEntry {
|
||||
readonly timestamp: number;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding-window bandwidth-quota enforcer. Each peer or path id has its
|
||||
* own quota and rolling history. `record(id, bytes)` is the throttled
|
||||
* write; `assertWithin(id, bytes)` returns silently if the next write
|
||||
* would stay within the window quota and throws `QUOTA_EXCEEDED`
|
||||
* otherwise.
|
||||
*/
|
||||
export class BandwidthQuotaEnforcer {
|
||||
private readonly windowMs: number;
|
||||
private readonly defaultQuotaBytes: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly history = new Map<string, UsageEntry[]>();
|
||||
private readonly quotas = new Map<string, number>();
|
||||
|
||||
public constructor(config: BandwidthQuotaEnforcerConfig = {}) {
|
||||
this.windowMs = config.windowMs ?? 1_000;
|
||||
this.defaultQuotaBytes = config.defaultQuotaBytes ?? 1_048_576;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public setQuota(id: string, bytes: number): void {
|
||||
this.quotas.set(id, bytes);
|
||||
}
|
||||
|
||||
public getQuota(id: string): number {
|
||||
return this.quotas.get(id) ?? this.defaultQuotaBytes;
|
||||
}
|
||||
|
||||
public usedBytes(id: string): number {
|
||||
return this.collect(id).reduce((sum, e) => sum + e.bytes, 0);
|
||||
}
|
||||
|
||||
public assertWithin(id: string, bytes: number): void {
|
||||
const used = this.usedBytes(id);
|
||||
if (used + bytes > this.getQuota(id)) {
|
||||
throw new QosError(QOS_CODES.QUOTA_EXCEEDED, "qos.quota-exceeded", {
|
||||
context: {
|
||||
id,
|
||||
used,
|
||||
requested: bytes,
|
||||
quota: this.getQuota(id),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public record(id: string, bytes: number): void {
|
||||
this.assertWithin(id, bytes);
|
||||
const list = this.collect(id);
|
||||
list.push({ timestamp: this.clock(), bytes });
|
||||
this.history.set(id, list);
|
||||
}
|
||||
|
||||
public clear(id?: string): void {
|
||||
if (id === undefined) {
|
||||
this.history.clear();
|
||||
return;
|
||||
}
|
||||
this.history.delete(id);
|
||||
}
|
||||
|
||||
private collect(id: string): UsageEntry[] {
|
||||
const cutoff = this.clock() - this.windowMs;
|
||||
const list = (this.history.get(id) ?? []).filter(
|
||||
(e) => e.timestamp > cutoff
|
||||
);
|
||||
this.history.set(id, list);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BandwidthQuotaEnforcer } from "./bandwidth-quota-enforcer.js";
|
||||
|
||||
describe("BandwidthQuotaEnforcer", () => {
|
||||
it("allows writes within the default quota", () => {
|
||||
const e = new BandwidthQuotaEnforcer({
|
||||
defaultQuotaBytes: 1_000,
|
||||
});
|
||||
e.record("alice", 500);
|
||||
e.record("alice", 400);
|
||||
expect(e.usedBytes("alice")).toBe(900);
|
||||
});
|
||||
|
||||
it("rejects writes that breach the quota", () => {
|
||||
const e = new BandwidthQuotaEnforcer({
|
||||
defaultQuotaBytes: 1_000,
|
||||
});
|
||||
e.record("alice", 800);
|
||||
expect(() => e.record("alice", 300)).toThrow("qos.quota-exceeded");
|
||||
});
|
||||
|
||||
it("respects per-id quotas", () => {
|
||||
const e = new BandwidthQuotaEnforcer({
|
||||
defaultQuotaBytes: 100,
|
||||
});
|
||||
e.setQuota("alice", 1_000);
|
||||
e.record("alice", 800);
|
||||
expect(e.usedBytes("alice")).toBe(800);
|
||||
expect(e.getQuota("alice")).toBe(1_000);
|
||||
expect(e.getQuota("bob")).toBe(100);
|
||||
});
|
||||
|
||||
it("slides the window when the clock advances", () => {
|
||||
let now = 0;
|
||||
const e = new BandwidthQuotaEnforcer({
|
||||
windowMs: 1_000,
|
||||
defaultQuotaBytes: 1_000,
|
||||
clock: () => now,
|
||||
});
|
||||
e.record("alice", 800);
|
||||
now += 2_000;
|
||||
// window has rolled, used resets to 0
|
||||
expect(e.usedBytes("alice")).toBe(0);
|
||||
e.record("alice", 800);
|
||||
});
|
||||
|
||||
it("assertWithin throws without recording", () => {
|
||||
const e = new BandwidthQuotaEnforcer({
|
||||
defaultQuotaBytes: 100,
|
||||
});
|
||||
expect(() => e.assertWithin("alice", 200)).toThrow(
|
||||
"qos.quota-exceeded"
|
||||
);
|
||||
expect(e.usedBytes("alice")).toBe(0);
|
||||
});
|
||||
|
||||
it("clear(id) forgets one peer's history", () => {
|
||||
const e = new BandwidthQuotaEnforcer({
|
||||
defaultQuotaBytes: 1_000,
|
||||
});
|
||||
e.record("alice", 500);
|
||||
e.record("bob", 500);
|
||||
e.clear("alice");
|
||||
expect(e.usedBytes("alice")).toBe(0);
|
||||
expect(e.usedBytes("bob")).toBe(500);
|
||||
});
|
||||
|
||||
it("clear() empties everything", () => {
|
||||
const e = new BandwidthQuotaEnforcer({
|
||||
defaultQuotaBytes: 1_000,
|
||||
});
|
||||
e.record("alice", 500);
|
||||
e.record("bob", 500);
|
||||
e.clear();
|
||||
expect(e.usedBytes("alice")).toBe(0);
|
||||
expect(e.usedBytes("bob")).toBe(0);
|
||||
});
|
||||
|
||||
it("uses Date.now-based clock when none injected", () => {
|
||||
const e = new BandwidthQuotaEnforcer({ defaultQuotaBytes: 1_000 });
|
||||
e.record("alice", 100);
|
||||
expect(e.usedBytes("alice")).toBe(100);
|
||||
});
|
||||
|
||||
it("uses default windowMs and defaultQuotaBytes when no config given", () => {
|
||||
const e = new BandwidthQuotaEnforcer();
|
||||
expect(e.getQuota("alice")).toBe(1_048_576);
|
||||
e.record("alice", 100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
# QOS_CODES
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of error codes for the network-plugin-qos plugin. One code covers the wire surface: `QUOTA_EXCEEDED` is thrown by the bandwidth enforcer when a peer or path exceeds its allocation. The router communicates other QoS conditions (drop, defer, queue, bypass) via the `QoSAction` enum on the `QoSDecision` shape rather than thrown errors, because those conditions are normal outcomes that the caller is expected to handle without exception flow. Companion `QosError` extends the cross-package `SuaveplanError` so consumers pattern-match on `code` rather than parse messages.
|
||||
|
||||
## Features
|
||||
|
||||
- Single frozen code registered under the `NET_QOS` namespace.
|
||||
- `QosError extends SuaveplanError<QosErrorCode>`.
|
||||
- `name: "QosError"` on every instance — useful for log filters and dashboard alerts.
|
||||
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
|
||||
- Codes are stable across versions; the registry only ever grows, never shrinks.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { QOS_CODES, QosError } from "@suaveplan/network-plugin-qos";
|
||||
|
||||
throw new QosError(QOS_CODES.QUOTA_EXCEEDED, "qos.quota-exceeded", {
|
||||
context: { peerId: "alice", quotaBytes: 1_000_000 },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { QOS_CODES, QosError } from "@suaveplan/network-plugin-qos";
|
||||
|
||||
try {
|
||||
enforcer.record("alice", 100_000);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof QosError &&
|
||||
err.code === QOS_CODES.QUOTA_EXCEEDED
|
||||
) {
|
||||
metrics.counter("qos.quota_breached").increment();
|
||||
await waitForWindowRoll();
|
||||
return enforcer.record("alice", 100_000);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `QOS_CODES` — frozen `{ QUOTA_EXCEEDED }`.
|
||||
- `type QosErrorCode` — union of registered code values.
|
||||
- `class QosError extends SuaveplanError<QosErrorCode>` — typed throwable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The single-code registry is intentional. Drop, defer, and queue decisions are returned via the `QoSDecision.action` enum because they are routine outcomes; baking them into thrown exceptions would force every send-site to wrap a try-catch. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too. Future failure modes (e.g. policy violations from operators) should be added as new `NET_QOS.*` codes rather than overloading `QUOTA_EXCEEDED`.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { QOS_CODES, QosError } from "./error-codes.js";
|
||||
|
||||
describe("QOS_CODES", () => {
|
||||
it("registers under NET_QOS", () => {
|
||||
for (const value of Object.values(QOS_CODES)) {
|
||||
expect(value).toMatch(/^NET_QOS\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(QOS_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
for (const key of ["QUOTA_EXCEEDED"] as const) {
|
||||
expect(QOS_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("QosError", () => {
|
||||
it("extends SuaveplanError", () => {
|
||||
const err = new QosError(
|
||||
QOS_CODES.QUOTA_EXCEEDED,
|
||||
"qos.quota-exceeded"
|
||||
);
|
||||
expect(err.name).toBe("QosError");
|
||||
expect(err.code).toBe(QOS_CODES.QUOTA_EXCEEDED);
|
||||
});
|
||||
|
||||
it("preserves cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new QosError(QOS_CODES.QUOTA_EXCEEDED, "with-cause", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const QOS_CODES = defineCodes("NET_QOS", ["QUOTA_EXCEEDED"] as const);
|
||||
|
||||
export type QosErrorCode = (typeof QOS_CODES)[keyof typeof QOS_CODES];
|
||||
|
||||
export class QosError extends SuaveplanError<QosErrorCode> {
|
||||
public constructor(
|
||||
code: QosErrorCode,
|
||||
messageKey: string,
|
||||
options?: import("@suaveplan/error").SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "QosError";
|
||||
}
|
||||
}
|
||||
30
packages/networking/network/network-plugin-qos/src/index.ts
Normal file
30
packages/networking/network/network-plugin-qos/src/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Error codes (side-effect registration)
|
||||
import "./error-codes.js";
|
||||
|
||||
// Classifier
|
||||
export {
|
||||
type ClassificationRule,
|
||||
MessagePriorityClassifier,
|
||||
type MessagePriorityClassifierConfig,
|
||||
type QoSClass,
|
||||
} from "./classifier/message-priority-classifier.js";
|
||||
// Enforcer
|
||||
export {
|
||||
BandwidthQuotaEnforcer,
|
||||
type BandwidthQuotaEnforcerConfig,
|
||||
} from "./enforcer/bandwidth-quota-enforcer.js";
|
||||
// Errors
|
||||
export { QOS_CODES, QosError, type QosErrorCode } from "./error-codes.js";
|
||||
// Monitor
|
||||
export {
|
||||
type CongestionListener,
|
||||
CongestionMonitor,
|
||||
type CongestionMonitorConfig,
|
||||
} from "./monitor/congestion-monitor.js";
|
||||
// Router
|
||||
export {
|
||||
type QoSAction,
|
||||
type QoSDecision,
|
||||
QoSRouter,
|
||||
type QoSRouterConfig,
|
||||
} from "./router/qos-router.js";
|
||||
@@ -0,0 +1,65 @@
|
||||
# CongestionMonitor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Tracks `(observed / capacity)` ratios over time and surfaces a stable congested/recovered signal — only flips state once the breach has been sustained for the configured `sustainedMs`. This avoids a single noisy spike triggering bulk-drops or aggressive defers; the `QoSRouter` consults `isCongested()` to make routing decisions.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `thresholdRatio` (default 0.8) and `sustainedMs` (default 5 s).
|
||||
- `observe(observed, capacity)` is the only ingestion point — no internal sampling, no leaked timers.
|
||||
- State flips only after the breach has been sustained.
|
||||
- Listeners fire on every `congested` / `recovered` transition; unsubscribe-returning `on()`.
|
||||
- Pluggable `clock` for deterministic tests.
|
||||
- `reset()` snaps back to "not congested" and fires a `recovered` event if applicable.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CongestionMonitor } from "@suaveplan/network-plugin-qos";
|
||||
|
||||
const monitor = new CongestionMonitor();
|
||||
monitor.observe(1_500_000, 1_000_000); // 150% — breach
|
||||
|
||||
if (monitor.isCongested()) {
|
||||
pauseBulkTraffic();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CongestionMonitor } from "@suaveplan/network-plugin-qos";
|
||||
|
||||
let now = 0;
|
||||
const monitor = new CongestionMonitor({
|
||||
thresholdRatio: 0.7,
|
||||
sustainedMs: 1_000,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
monitor.on((state) => {
|
||||
metrics.gauge("qos.congested", state === "congested" ? 1 : 0);
|
||||
});
|
||||
|
||||
for (const window of windows) {
|
||||
now = window.timestamp;
|
||||
monitor.observe(window.bytes, window.capacity);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CongestionMonitorConfig { thresholdRatio?, sustainedMs?, clock? }`.
|
||||
- `type CongestionListener = (state: "congested" | "recovered") => void`.
|
||||
- `observe(observed, capacity)` — ingestion.
|
||||
- `isCongested(): boolean`.
|
||||
- `on(listener) → () => void`.
|
||||
- `reset()` — clears state and fires `recovered` if currently congested.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `breachStart` watermark is captured on the first breach observation and cleared the first time the ratio drops back under threshold. Sustained-state requires the breach to persist _continuously_ — any single recovery resets the timer. `capacity === 0` is treated as 100% utilisation rather than producing `NaN`, because the sane interpretation of "no capacity" is "definitely congested". The monitor does not own a clock-driven loop — consumers MUST call `observe` whenever they have a fresh reading.
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CongestionMonitor } from "./congestion-monitor.js";
|
||||
|
||||
describe("CongestionMonitor", () => {
|
||||
it("starts uncongested", () => {
|
||||
const m = new CongestionMonitor();
|
||||
expect(m.isCongested()).toBe(false);
|
||||
});
|
||||
|
||||
it("flips to congested only after sustainedMs", () => {
|
||||
let now = 0;
|
||||
const m = new CongestionMonitor({
|
||||
thresholdRatio: 0.5,
|
||||
sustainedMs: 100,
|
||||
clock: () => now,
|
||||
});
|
||||
m.observe(80, 100); // breach
|
||||
expect(m.isCongested()).toBe(false);
|
||||
now = 200;
|
||||
m.observe(80, 100);
|
||||
expect(m.isCongested()).toBe(true);
|
||||
});
|
||||
|
||||
it("recovers when ratio drops below threshold", () => {
|
||||
let now = 0;
|
||||
const m = new CongestionMonitor({
|
||||
thresholdRatio: 0.5,
|
||||
sustainedMs: 0,
|
||||
clock: () => now,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
m.on((s) => seen.push(s));
|
||||
m.observe(80, 100);
|
||||
expect(m.isCongested()).toBe(true);
|
||||
now = 100;
|
||||
m.observe(20, 100);
|
||||
expect(m.isCongested()).toBe(false);
|
||||
expect(seen).toEqual(["congested", "recovered"]);
|
||||
});
|
||||
|
||||
it("treats capacity=0 as 100% utilisation", () => {
|
||||
const now = 0;
|
||||
const m = new CongestionMonitor({
|
||||
thresholdRatio: 0.5,
|
||||
sustainedMs: 0,
|
||||
clock: () => now,
|
||||
});
|
||||
m.observe(0, 0);
|
||||
expect(m.isCongested()).toBe(true);
|
||||
});
|
||||
|
||||
it("breach reset clears the timer", () => {
|
||||
let now = 0;
|
||||
const m = new CongestionMonitor({
|
||||
thresholdRatio: 0.5,
|
||||
sustainedMs: 100,
|
||||
clock: () => now,
|
||||
});
|
||||
m.observe(80, 100);
|
||||
now = 50;
|
||||
m.observe(20, 100); // recovery before sustainedMs
|
||||
now = 75;
|
||||
m.observe(80, 100); // new breach starts at 75
|
||||
now = 150; // 75ms into new breach, not yet sustained
|
||||
m.observe(80, 100);
|
||||
expect(m.isCongested()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses default threshold/sustained", () => {
|
||||
const m = new CongestionMonitor();
|
||||
m.observe(50, 100); // 50% — under default 80% threshold
|
||||
expect(m.isCongested()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Date.now-based clock by default", () => {
|
||||
const m = new CongestionMonitor({
|
||||
thresholdRatio: 0.5,
|
||||
sustainedMs: 0,
|
||||
});
|
||||
m.observe(80, 100);
|
||||
expect(m.isCongested()).toBe(true);
|
||||
});
|
||||
|
||||
it("on() returns an unsubscribe handle", () => {
|
||||
const m = new CongestionMonitor({
|
||||
thresholdRatio: 0.5,
|
||||
sustainedMs: 0,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
const off = m.on((s) => seen.push(s));
|
||||
m.observe(80, 100);
|
||||
off();
|
||||
m.observe(20, 100);
|
||||
expect(seen).toEqual(["congested"]);
|
||||
});
|
||||
|
||||
it("reset clears congested state and emits recovered", () => {
|
||||
const m = new CongestionMonitor({
|
||||
thresholdRatio: 0.5,
|
||||
sustainedMs: 0,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
m.on((s) => seen.push(s));
|
||||
m.observe(80, 100);
|
||||
m.reset();
|
||||
expect(m.isCongested()).toBe(false);
|
||||
expect(seen).toContain("recovered");
|
||||
});
|
||||
|
||||
it("reset on already-recovered state is a no-op", () => {
|
||||
const m = new CongestionMonitor();
|
||||
const seen: string[] = [];
|
||||
m.on((s) => seen.push(s));
|
||||
m.reset();
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Configuration for `CongestionMonitor`.
|
||||
*/
|
||||
export interface CongestionMonitorConfig {
|
||||
readonly thresholdRatio?: number;
|
||||
readonly sustainedMs?: number;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener fired when the monitor enters or leaves congestion.
|
||||
*/
|
||||
export type CongestionListener = (state: "congested" | "recovered") => void;
|
||||
|
||||
/**
|
||||
* Tracks `(observed / capacity)` ratios over time and surfaces a stable
|
||||
* congested/recovered signal — only flips state once the breach has
|
||||
* been sustained for `sustainedMs`. This avoids a single noisy spike
|
||||
* triggering bulk drops or aggressive defers.
|
||||
*/
|
||||
export class CongestionMonitor {
|
||||
private readonly thresholdRatio: number;
|
||||
private readonly sustainedMs: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly listeners = new Set<CongestionListener>();
|
||||
private breachStart: number | null = null;
|
||||
private currentlyCongested = false;
|
||||
|
||||
public constructor(config: CongestionMonitorConfig = {}) {
|
||||
this.thresholdRatio = config.thresholdRatio ?? 0.8;
|
||||
this.sustainedMs = config.sustainedMs ?? 5_000;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public observe(observed: number, capacity: number): void {
|
||||
const ratio = capacity === 0 ? 1 : observed / capacity;
|
||||
const breached = ratio >= this.thresholdRatio;
|
||||
const now = this.clock();
|
||||
if (breached) {
|
||||
if (this.breachStart === null) this.breachStart = now;
|
||||
if (
|
||||
!this.currentlyCongested &&
|
||||
now - this.breachStart >= this.sustainedMs
|
||||
) {
|
||||
this.currentlyCongested = true;
|
||||
this.fire("congested");
|
||||
}
|
||||
} else {
|
||||
this.breachStart = null;
|
||||
if (this.currentlyCongested) {
|
||||
this.currentlyCongested = false;
|
||||
this.fire("recovered");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public isCongested(): boolean {
|
||||
return this.currentlyCongested;
|
||||
}
|
||||
|
||||
public on(listener: CongestionListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.breachStart = null;
|
||||
if (this.currentlyCongested) {
|
||||
this.currentlyCongested = false;
|
||||
this.fire("recovered");
|
||||
}
|
||||
}
|
||||
|
||||
private fire(state: "congested" | "recovered"): void {
|
||||
for (const l of this.listeners) l(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# QoSRouter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Decides what the surrounding transport pipeline should do with an envelope: send it (with quota record), bypass quota for urgent traffic, queue control traffic when quota is breached, defer data traffic for retry, or drop bulk under congestion. Combines the `MessagePriorityClassifier` (what kind of traffic is this?) with the `BandwidthQuotaEnforcer` (do we have headroom?) into one policy decision.
|
||||
|
||||
## Features
|
||||
|
||||
- Five actions: `bypass`, `send`, `queue`, `drop`, `defer`.
|
||||
- `URGENT` traffic always bypasses the quota.
|
||||
- `BULK` traffic drops under congestion.
|
||||
- `CONTROL` quota breaches → `queue`; everything else → `defer`.
|
||||
- Pluggable `congested` predicate so `CongestionMonitor` (or any external observer) can drive the bypass/drop logic.
|
||||
- `commitSend(pathId, sizeBytes)` records the send against the quota only after the consumer has actually transmitted.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
BandwidthQuotaEnforcer,
|
||||
MessagePriorityClassifier,
|
||||
QoSRouter,
|
||||
} from "@suaveplan/network-plugin-qos";
|
||||
|
||||
const router = new QoSRouter({
|
||||
classifier: new MessagePriorityClassifier(),
|
||||
enforcer: new BandwidthQuotaEnforcer(),
|
||||
});
|
||||
|
||||
const decision = router.decide(envelope, payloadSize, "alice→bob");
|
||||
if (decision.action === "send") {
|
||||
await transport.send(envelope);
|
||||
router.commitSend("alice→bob", payloadSize);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { QoSRouter } from "@suaveplan/network-plugin-qos";
|
||||
|
||||
const monitor = new CongestionMonitor({ threshold: 0.8 });
|
||||
const router = new QoSRouter({
|
||||
classifier,
|
||||
enforcer,
|
||||
congested: () => monitor.isCongested(),
|
||||
});
|
||||
|
||||
const decision = router.decide(envelope, sizeBytes, pathId);
|
||||
switch (decision.action) {
|
||||
case "bypass":
|
||||
return forceSend(envelope);
|
||||
case "send":
|
||||
await transport.send(envelope);
|
||||
router.commitSend(pathId, sizeBytes);
|
||||
return;
|
||||
case "queue":
|
||||
controlQueue.push(envelope);
|
||||
return;
|
||||
case "defer":
|
||||
deferQueue.push({ envelope, retryAfterMs: 100 });
|
||||
return;
|
||||
case "drop":
|
||||
metrics.counter("qos.dropped").increment();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type QoSAction = "bypass" | "send" | "queue" | "drop" | "defer"`.
|
||||
- `interface QoSDecision { action, class, reason? }`.
|
||||
- `interface QoSRouterConfig { classifier, enforcer, congested? }`.
|
||||
- `decide(envelope, sizeBytes, pathId): QoSDecision`.
|
||||
- `commitSend(pathId, sizeBytes)` — records bytes against the quota.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`decide()` does not record the send — `commitSend` is split out so the consumer only counts bytes that actually went on the wire. This avoids double-counting when the consumer's transport has its own retry logic. The router throws non-`QUOTA_EXCEEDED` errors from the enforcer through unchanged, on the assumption that a different `QosErrorCode` indicates a programmer error rather than a recoverable QoS condition.
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MessagePriorityClassifier } from "../classifier/message-priority-classifier.js";
|
||||
import { BandwidthQuotaEnforcer } from "../enforcer/bandwidth-quota-enforcer.js";
|
||||
import { QoSRouter } from "./qos-router.js";
|
||||
|
||||
function envelope(type: string): MessageEnvelope {
|
||||
return {
|
||||
id: "x",
|
||||
type,
|
||||
senderId: "self",
|
||||
payload: null,
|
||||
timestamp: 0,
|
||||
headers: {},
|
||||
ttl: 8,
|
||||
visitedNodes: [],
|
||||
} as MessageEnvelope;
|
||||
}
|
||||
|
||||
describe("QoSRouter", () => {
|
||||
function setup(opts: { congested?: boolean; quota?: number } = {}) {
|
||||
const enforcer = new BandwidthQuotaEnforcer({
|
||||
defaultQuotaBytes: opts.quota ?? 10_000,
|
||||
});
|
||||
const classifier = new MessagePriorityClassifier();
|
||||
const router = new QoSRouter({
|
||||
classifier,
|
||||
enforcer,
|
||||
congested: () => opts.congested ?? false,
|
||||
});
|
||||
return { router, enforcer, classifier };
|
||||
}
|
||||
|
||||
it("URGENT bypasses everything", () => {
|
||||
const { router } = setup({ quota: 1, congested: true });
|
||||
const decision = router.decide(envelope("election:start"), 100, "p");
|
||||
expect(decision.action).toBe("bypass");
|
||||
expect(decision.class).toBe("URGENT");
|
||||
});
|
||||
|
||||
it("BULK drops under congestion", () => {
|
||||
const { router } = setup({ congested: true });
|
||||
const decision = router.decide(envelope("audit:append"), 100, "p");
|
||||
expect(decision.action).toBe("drop");
|
||||
expect(decision.class).toBe("BULK");
|
||||
});
|
||||
|
||||
it("CONTROL queues when quota is exceeded", () => {
|
||||
const { router } = setup({ quota: 50 });
|
||||
const decision = router.decide(envelope("registry:add"), 100, "p");
|
||||
expect(decision.action).toBe("queue");
|
||||
expect(decision.class).toBe("CONTROL");
|
||||
});
|
||||
|
||||
it("DATA defers when quota is exceeded", () => {
|
||||
const { router } = setup({ quota: 50 });
|
||||
const decision = router.decide(envelope("game:tick"), 100, "p");
|
||||
expect(decision.action).toBe("defer");
|
||||
expect(decision.class).toBe("DATA");
|
||||
});
|
||||
|
||||
it("sends and commits when within budget", () => {
|
||||
const { router, enforcer } = setup();
|
||||
const decision = router.decide(envelope("game:tick"), 100, "p");
|
||||
expect(decision.action).toBe("send");
|
||||
router.commitSend("p", 100);
|
||||
expect(enforcer.usedBytes("p")).toBe(100);
|
||||
});
|
||||
|
||||
it("does not commit on bypass (caller decides)", () => {
|
||||
const { router, enforcer } = setup();
|
||||
const decision = router.decide(envelope("failover:hint"), 100, "p");
|
||||
expect(decision.action).toBe("bypass");
|
||||
expect(enforcer.usedBytes("p")).toBe(0);
|
||||
});
|
||||
|
||||
it("default congested predicate is false", () => {
|
||||
const enforcer = new BandwidthQuotaEnforcer({
|
||||
defaultQuotaBytes: 10_000,
|
||||
});
|
||||
const router = new QoSRouter({
|
||||
classifier: new MessagePriorityClassifier(),
|
||||
enforcer,
|
||||
});
|
||||
const decision = router.decide(envelope("audit:append"), 100, "p");
|
||||
// not congested → BULK still goes through quota path
|
||||
expect(decision.action).toBe("send");
|
||||
});
|
||||
|
||||
it("rethrows non-quota errors from the enforcer", () => {
|
||||
const enforcer = {
|
||||
assertWithin: () => {
|
||||
throw new Error("non-qos");
|
||||
},
|
||||
} as never;
|
||||
const router = new QoSRouter({
|
||||
classifier: new MessagePriorityClassifier(),
|
||||
enforcer,
|
||||
});
|
||||
expect(() => router.decide(envelope("game:tick"), 100, "p")).toThrow(
|
||||
"non-qos"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import type {
|
||||
MessagePriorityClassifier,
|
||||
QoSClass,
|
||||
} from "../classifier/message-priority-classifier.js";
|
||||
import type { BandwidthQuotaEnforcer } from "../enforcer/bandwidth-quota-enforcer.js";
|
||||
import { QOS_CODES, QosError } from "../error-codes.js";
|
||||
|
||||
/**
|
||||
* Action the router takes for one envelope.
|
||||
*/
|
||||
export type QoSAction = "bypass" | "send" | "queue" | "drop" | "defer";
|
||||
|
||||
/**
|
||||
* Result of `decide()`.
|
||||
*/
|
||||
export interface QoSDecision {
|
||||
readonly action: QoSAction;
|
||||
readonly class: QoSClass;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `QoSRouter`.
|
||||
*/
|
||||
export interface QoSRouterConfig {
|
||||
readonly classifier: MessagePriorityClassifier;
|
||||
readonly enforcer: BandwidthQuotaEnforcer;
|
||||
readonly congested?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what the surrounding transport pipeline should do with an
|
||||
* envelope: send it (with quota-record), bypass quota for urgent
|
||||
* traffic, queue when congested, drop bulk under congestion, or defer
|
||||
* for retry.
|
||||
*/
|
||||
export class QoSRouter {
|
||||
private readonly classifier: MessagePriorityClassifier;
|
||||
private readonly enforcer: BandwidthQuotaEnforcer;
|
||||
private readonly isCongested: () => boolean;
|
||||
|
||||
public constructor(config: QoSRouterConfig) {
|
||||
this.classifier = config.classifier;
|
||||
this.enforcer = config.enforcer;
|
||||
this.isCongested = config.congested ?? (() => false);
|
||||
}
|
||||
|
||||
public decide(
|
||||
envelope: MessageEnvelope,
|
||||
sizeBytes: number,
|
||||
pathId: string
|
||||
): QoSDecision {
|
||||
const cls = this.classifier.classify(envelope, sizeBytes);
|
||||
const congested = this.isCongested();
|
||||
if (cls === "URGENT") {
|
||||
return { action: "bypass", class: cls };
|
||||
}
|
||||
if (congested && cls === "BULK") {
|
||||
return {
|
||||
action: "drop",
|
||||
class: cls,
|
||||
reason: "congested-and-bulk",
|
||||
};
|
||||
}
|
||||
try {
|
||||
this.enforcer.assertWithin(pathId, sizeBytes);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof QosError &&
|
||||
err.code === QOS_CODES.QUOTA_EXCEEDED
|
||||
) {
|
||||
return cls === "CONTROL"
|
||||
? { action: "queue", class: cls, reason: "quota-exceeded" }
|
||||
: { action: "defer", class: cls, reason: "quota-exceeded" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { action: "send", class: cls };
|
||||
}
|
||||
|
||||
public commitSend(pathId: string, sizeBytes: number): void {
|
||||
this.enforcer.record(pathId, sizeBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-plugin-qos dep-graph", () => {
|
||||
it("loads index.js (acyclic static imports)", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod.BandwidthQuotaEnforcer).toBeDefined();
|
||||
expect(mod.MessagePriorityClassifier).toBeDefined();
|
||||
expect(mod.QoSRouter).toBeDefined();
|
||||
expect(mod.CongestionMonitor).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/network-plugin-qos",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/network",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user