Initial Commit
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# @suaveplan/network-plugin-simulation - Deterministic virtual-network simulation framework for the Suaveplan mesh: NetworkSimulator (seeded RNG + time control + topology factories), VirtualNode, ScenarioRunner, NetworkConditionInjector, ChaosEngine. Dev/test only.
|
||||
|
||||
## Summary
|
||||
|
||||
Deterministic virtual-network simulation framework for the Suaveplan mesh. `NetworkSimulator` runs scenarios against a seeded RNG with simulated time (acceleration / deceleration vs wall clock) and four built-in topology factories (star / mesh / tiered / custom); `VirtualNode` wraps the same `NetworkNode` + plugin code that real peers use, so behaviour is bit-identical between simulation and production; `ScenarioRunner` executes declarative scenarios (setup → ordered events → success criteria → metric collection) with checkpointing for long runs; `NetworkConditionInjector` applies latency / loss / bandwidth / jitter / connection-disruption models per peer-pair; `ChaosEngine` injects random failures (peer crashes, transport faults, partitions, message drops) for chaos testing. Dev/test only — the production wrapper is a no-op when `NODE_ENV === "production"`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [chaos](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
|
||||
<!-- end:auto-docs --> - [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
|
||||
- [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
|
||||
- [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
|
||||
- [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_ - [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-simulation",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Deterministic virtual-network simulation framework for the Suaveplan mesh: NetworkSimulator (seeded RNG + time control + topology factories), VirtualNode, ScenarioRunner, NetworkConditionInjector, ChaosEngine. Dev/test only.",
|
||||
"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,81 @@
|
||||
# `ChaosEngine`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Probabilistic failure injector for chaos testing. Each call to `step(nodes, edges)` rolls four independent dice (crash / partition / message-drop / transport-fault) using the simulator's seeded RNG, applies the relevant mutation, and appends a `ChaosEvent` to the engine's event log. Used to push the mesh past the predefined-scenario surface and surface bugs that only appear under noisy real-world conditions.
|
||||
|
||||
## Features
|
||||
|
||||
- Four configurable per-step probabilities — set any to `0` (or omit) to disable that chaos type.
|
||||
- Crash and transport-fault both pick a random victim from the live `nodes` array; partition picks a random victim edge from the supplied edge list.
|
||||
- Message-drop is decoupled from a specific message — it fires based on the per-step probability so callers can pair drops with arbitrary downstream traffic.
|
||||
- Event log is per-engine and survives `step` calls so test assertions can read the full chaos history in one shot.
|
||||
- Pure RNG via the simulator means deterministic chaos under a fixed seed.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChaosEngine,
|
||||
NetworkSimulator,
|
||||
VirtualNode,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const nodes = [new VirtualNode({ id: "n1", tier: 0 }, sim)];
|
||||
const chaos = new ChaosEngine(sim, { crashProbability: 0.5 });
|
||||
const events = chaos.step(nodes, []);
|
||||
console.log(events);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChaosEngine,
|
||||
NetworkSimulator,
|
||||
VirtualNode,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 99 });
|
||||
const nodes = Array.from(
|
||||
{ length: 5 },
|
||||
(_, i) => new VirtualNode({ id: `n${i}`, tier: i === 0 ? 0 : 1 }, sim)
|
||||
);
|
||||
const edges = nodes
|
||||
.slice(1)
|
||||
.map((n) => ({ from: "n0", to: n.id }));
|
||||
|
||||
const chaos = new ChaosEngine(sim, {
|
||||
crashProbability: 0.05,
|
||||
partitionProbability: 0.02,
|
||||
messageDropProbability: 0.10,
|
||||
transportFaultProbability: 0.01,
|
||||
});
|
||||
|
||||
for (let tick = 0; tick < 100; tick += 1) {
|
||||
sim.advanceBy(1);
|
||||
chaos.step(nodes, edges);
|
||||
}
|
||||
console.log(chaos.eventLog().length);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ChaosEngine(simulator, config?)`
|
||||
- `update(config: ChaosConfig): void`.
|
||||
- `step(nodes, edges): ReadonlyArray<ChaosEvent>` — events emitted this step.
|
||||
- `eventLog(): ReadonlyArray<ChaosEvent>` — full log since construction.
|
||||
- `reset(): void` — clears the event log.
|
||||
- `interface ChaosConfig` — `{ crashProbability?, partitionProbability?, messageDropProbability?, transportFaultProbability? }`.
|
||||
- `interface ChaosEvent` — `{ atMs, type, target?, target2? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The engine never throws — chaos is intentionally noisy and the simulator should not abort because of an unlucky roll. Caller code that wants to refuse particular events should filter the event log post-hoc.
|
||||
- Crash skips victims that are already not-running; transport-fault does not, because a transport fault on a downed node is still a meaningful test event for downstream dispatchers.
|
||||
- `messageDropProbability` is not paired with a specific message because the engine does not know about message identities — pair it with a downstream layer that consults the event log for "should I drop the next outbound?" decisions.
|
||||
- The four probabilities are independent — every step can emit between zero and four events.
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import type { VirtualNode } from "../virtual-node/virtual-node.js";
|
||||
|
||||
export interface ChaosConfig {
|
||||
/** Per-step probability that a randomly-chosen node crashes. */
|
||||
readonly crashProbability?: number;
|
||||
/** Per-step probability that a randomly-chosen edge partitions. */
|
||||
readonly partitionProbability?: number;
|
||||
/** Per-step probability that the next message drops. */
|
||||
readonly messageDropProbability?: number;
|
||||
/** Per-step probability of a transport fault (returned in event log). */
|
||||
readonly transportFaultProbability?: number;
|
||||
}
|
||||
|
||||
export interface ChaosEvent {
|
||||
readonly atMs: number;
|
||||
readonly type: "crash" | "partition" | "drop" | "transport-fault";
|
||||
readonly target?: string;
|
||||
readonly target2?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probabilistic failure injector. Steps through (node, RNG roll)
|
||||
* pairs; consumers call `step(nodes, edges)` once per simulator tick
|
||||
* to roll for each chaos type.
|
||||
*/
|
||||
export class ChaosEngine {
|
||||
private readonly simulator: NetworkSimulator;
|
||||
private config: ChaosConfig;
|
||||
private readonly events: ChaosEvent[] = [];
|
||||
|
||||
public constructor(simulator: NetworkSimulator, config: ChaosConfig = {}) {
|
||||
this.simulator = simulator;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public update(config: ChaosConfig): void {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one chaos step. `nodes` is the live virtual-node set; `edges`
|
||||
* is the pair list the partition roll picks from. Returns the
|
||||
* events emitted this step.
|
||||
*/
|
||||
public step(
|
||||
nodes: ReadonlyArray<VirtualNode>,
|
||||
edges: ReadonlyArray<{ from: string; to: string }>
|
||||
): ReadonlyArray<ChaosEvent> {
|
||||
const stepEvents: ChaosEvent[] = [];
|
||||
this.maybeCrash(nodes, stepEvents);
|
||||
this.maybePartition(edges, stepEvents);
|
||||
this.maybeDrop(stepEvents);
|
||||
this.maybeTransportFault(nodes, stepEvents);
|
||||
for (const e of stepEvents) this.events.push(e);
|
||||
return stepEvents;
|
||||
}
|
||||
|
||||
public eventLog(): ReadonlyArray<ChaosEvent> {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.events.length = 0;
|
||||
}
|
||||
|
||||
private maybeCrash(
|
||||
nodes: ReadonlyArray<VirtualNode>,
|
||||
out: ChaosEvent[]
|
||||
): void {
|
||||
const probability = this.config.crashProbability ?? 0;
|
||||
if (probability <= 0 || nodes.length === 0) return;
|
||||
if (this.simulator.rand() >= probability) return;
|
||||
const idx = this.simulator.randInt(0, nodes.length);
|
||||
const target = nodes[idx] as VirtualNode;
|
||||
if (target.getStatus() !== "running") return;
|
||||
target.crash();
|
||||
out.push({
|
||||
atMs: this.simulator.now(),
|
||||
type: "crash",
|
||||
target: target.id,
|
||||
});
|
||||
}
|
||||
|
||||
private maybePartition(
|
||||
edges: ReadonlyArray<{ from: string; to: string }>,
|
||||
out: ChaosEvent[]
|
||||
): void {
|
||||
const probability = this.config.partitionProbability ?? 0;
|
||||
if (probability <= 0 || edges.length === 0) return;
|
||||
if (this.simulator.rand() >= probability) return;
|
||||
const idx = this.simulator.randInt(0, edges.length);
|
||||
const edge = edges[idx] as { from: string; to: string };
|
||||
out.push({
|
||||
atMs: this.simulator.now(),
|
||||
type: "partition",
|
||||
target: edge.from,
|
||||
target2: edge.to,
|
||||
});
|
||||
}
|
||||
|
||||
private maybeDrop(out: ChaosEvent[]): void {
|
||||
const probability = this.config.messageDropProbability ?? 0;
|
||||
if (probability <= 0) return;
|
||||
if (this.simulator.rand() >= probability) return;
|
||||
out.push({ atMs: this.simulator.now(), type: "drop" });
|
||||
}
|
||||
|
||||
private maybeTransportFault(
|
||||
nodes: ReadonlyArray<VirtualNode>,
|
||||
out: ChaosEvent[]
|
||||
): void {
|
||||
const probability = this.config.transportFaultProbability ?? 0;
|
||||
if (probability <= 0 || nodes.length === 0) return;
|
||||
if (this.simulator.rand() >= probability) return;
|
||||
const idx = this.simulator.randInt(0, nodes.length);
|
||||
const target = nodes[idx] as VirtualNode;
|
||||
out.push({
|
||||
atMs: this.simulator.now(),
|
||||
type: "transport-fault",
|
||||
target: target.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import { VirtualNode } from "../virtual-node/virtual-node.js";
|
||||
import { ChaosEngine } from "./chaos-engine.js";
|
||||
|
||||
const setup = (configOverrides = {}) => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const nodes = Array.from(
|
||||
{ length: 4 },
|
||||
(_, i) => new VirtualNode({ id: `n${i}`, tier: 0 }, sim)
|
||||
);
|
||||
const edges = nodes.slice(1).map((n) => ({ from: "n0", to: n.id }));
|
||||
return {
|
||||
sim,
|
||||
nodes,
|
||||
edges,
|
||||
chaos: new ChaosEngine(sim, configOverrides),
|
||||
};
|
||||
};
|
||||
|
||||
describe("ChaosEngine: per-step probabilistic failure injection", () => {
|
||||
it("default config emits zero events", () => {
|
||||
const { nodes, edges, chaos } = setup({});
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
chaos.step(nodes, edges);
|
||||
}
|
||||
expect(chaos.eventLog().length).toBe(0);
|
||||
});
|
||||
|
||||
it("crashProbability=1 crashes a random node", () => {
|
||||
const { nodes, edges, chaos } = setup({ crashProbability: 1 });
|
||||
chaos.step(nodes, edges);
|
||||
const events = chaos.eventLog();
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]?.type).toBe("crash");
|
||||
});
|
||||
|
||||
it("crash skips already-crashed victims", () => {
|
||||
const { nodes, edges, chaos } = setup({ crashProbability: 1 });
|
||||
nodes.forEach((n) => n.crash());
|
||||
const events = chaos.step(nodes, edges);
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
it("partition picks an edge", () => {
|
||||
const { nodes, edges, chaos } = setup({ partitionProbability: 1 });
|
||||
const events = chaos.step(nodes, edges);
|
||||
expect(events.find((e) => e.type === "partition")).toBeDefined();
|
||||
});
|
||||
|
||||
it("messageDrop emits a drop event", () => {
|
||||
const { nodes, edges, chaos } = setup({ messageDropProbability: 1 });
|
||||
const events = chaos.step(nodes, edges);
|
||||
expect(events.find((e) => e.type === "drop")).toBeDefined();
|
||||
});
|
||||
|
||||
it("transportFault picks a random target", () => {
|
||||
const { nodes, edges, chaos } = setup({
|
||||
transportFaultProbability: 1,
|
||||
});
|
||||
const events = chaos.step(nodes, edges);
|
||||
expect(events.find((e) => e.type === "transport-fault")).toBeDefined();
|
||||
});
|
||||
|
||||
it("zero / unset probabilities are no-ops", () => {
|
||||
const { nodes, edges, chaos } = setup({
|
||||
crashProbability: 0,
|
||||
partitionProbability: 0,
|
||||
messageDropProbability: 0,
|
||||
transportFaultProbability: 0,
|
||||
});
|
||||
const events = chaos.step(nodes, edges);
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
it("crashProbability with zero nodes is a no-op", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const chaos = new ChaosEngine(sim, { crashProbability: 1 });
|
||||
const events = chaos.step([], []);
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
it("partition with zero edges is a no-op", () => {
|
||||
const { nodes, chaos } = setup({ partitionProbability: 1 });
|
||||
const events = chaos.step(nodes, []);
|
||||
expect(events.find((e) => e.type === "partition")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("transportFault with zero nodes is a no-op", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const chaos = new ChaosEngine(sim, { transportFaultProbability: 1 });
|
||||
const events = chaos.step([], []);
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
it("update replaces the active config", () => {
|
||||
const { nodes, edges, chaos } = setup({});
|
||||
chaos.update({ messageDropProbability: 1 });
|
||||
const events = chaos.step(nodes, edges);
|
||||
expect(events.find((e) => e.type === "drop")).toBeDefined();
|
||||
});
|
||||
|
||||
it("eventLog accumulates across steps; reset clears", () => {
|
||||
const { nodes, edges, chaos } = setup({ messageDropProbability: 1 });
|
||||
chaos.step(nodes, edges);
|
||||
chaos.step(nodes, edges);
|
||||
expect(chaos.eventLog().length).toBe(2);
|
||||
chaos.reset();
|
||||
expect(chaos.eventLog().length).toBe(0);
|
||||
});
|
||||
|
||||
it("eventLog returns a defensive copy", () => {
|
||||
const { nodes, edges, chaos } = setup({ messageDropProbability: 1 });
|
||||
chaos.step(nodes, edges);
|
||||
const log = chaos.eventLog();
|
||||
(log as unknown as Array<unknown>).push(null);
|
||||
expect(chaos.eventLog().length).toBe(1);
|
||||
});
|
||||
|
||||
it("crashProbability roll that lands above the threshold skips", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const nodes = [new VirtualNode({ id: "n", tier: 0 }, sim)];
|
||||
const chaos = new ChaosEngine(sim, { crashProbability: 0.001 });
|
||||
chaos.step(nodes, []);
|
||||
expect(chaos.eventLog().length).toBe(0);
|
||||
});
|
||||
|
||||
it("non-zero probabilities that do not trip leave each branch a no-op", () => {
|
||||
// Probability 0.001 with seed=1 produces rolls > threshold so each
|
||||
// chaos branch exits without firing — exercises the early-return
|
||||
// path in maybeCrash / maybePartition / maybeDrop / maybeTransportFault.
|
||||
const { nodes, edges, chaos } = setup({
|
||||
crashProbability: 0.001,
|
||||
partitionProbability: 0.001,
|
||||
messageDropProbability: 0.001,
|
||||
transportFaultProbability: 0.001,
|
||||
});
|
||||
const events = chaos.step(nodes, edges);
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import { NetworkConditionInjector } from "./network-condition-injector.js";
|
||||
|
||||
const setup = (config: Parameters<typeof NetworkConditionInjector>[1]) => {
|
||||
const sim = new NetworkSimulator({ seed: 99 });
|
||||
return {
|
||||
sim,
|
||||
injector: new NetworkConditionInjector(sim, config),
|
||||
};
|
||||
};
|
||||
|
||||
describe("NetworkConditionInjector: latency / loss / bandwidth / jitter / disruption", () => {
|
||||
it("default config returns zero latency / no drops / infinite bandwidth", () => {
|
||||
const { injector } = setup({});
|
||||
const r = injector.inject({
|
||||
from: "a",
|
||||
to: "b",
|
||||
sizeBytes: 100,
|
||||
});
|
||||
expect(r.latencyMs).toBe(0);
|
||||
expect(r.dropped).toBe(false);
|
||||
expect(r.bandwidthBytesPerSec).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("constant latency model returns the configured value", () => {
|
||||
const { injector } = setup({
|
||||
latency: { kind: "constant", latencyMs: 75 },
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.latencyMs).toBe(75);
|
||||
});
|
||||
|
||||
it("random latency stays within [min, max)", () => {
|
||||
const { injector } = setup({
|
||||
latency: { kind: "random", minMs: 50, maxMs: 100 },
|
||||
});
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.latencyMs).toBeGreaterThanOrEqual(50);
|
||||
expect(r.latencyMs).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("geographic latency sums the per-region values", () => {
|
||||
const { injector } = setup({
|
||||
latency: {
|
||||
kind: "geographic",
|
||||
perRegionMs: { "us-east": 30, "eu-west": 80 },
|
||||
},
|
||||
});
|
||||
const r = injector.inject({
|
||||
from: "a",
|
||||
to: "b",
|
||||
fromRegion: "us-east",
|
||||
toRegion: "eu-west",
|
||||
sizeBytes: 1,
|
||||
});
|
||||
expect(r.latencyMs).toBe(110);
|
||||
});
|
||||
|
||||
it("geographic latency falls to 0 for unknown regions", () => {
|
||||
const { injector } = setup({
|
||||
latency: {
|
||||
kind: "geographic",
|
||||
perRegionMs: { "us-east": 30 },
|
||||
},
|
||||
});
|
||||
const r = injector.inject({
|
||||
from: "a",
|
||||
to: "b",
|
||||
fromRegion: "ap-south",
|
||||
toRegion: "us-east",
|
||||
sizeBytes: 1,
|
||||
});
|
||||
expect(r.latencyMs).toBe(30);
|
||||
});
|
||||
|
||||
it("geographic latency with no regions on the message returns 0", () => {
|
||||
const { injector } = setup({
|
||||
latency: {
|
||||
kind: "geographic",
|
||||
perRegionMs: { "us-east": 30 },
|
||||
},
|
||||
});
|
||||
const r = injector.inject({
|
||||
from: "a",
|
||||
to: "b",
|
||||
sizeBytes: 1,
|
||||
});
|
||||
expect(r.latencyMs).toBe(0);
|
||||
});
|
||||
|
||||
it("loss random eventually drops messages", () => {
|
||||
const { injector } = setup({
|
||||
loss: { kind: "random", probability: 1 },
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.droppedByLoss).toBe(true);
|
||||
expect(r.dropped).toBe(true);
|
||||
});
|
||||
|
||||
it("loss burst with zero burst length never drops", () => {
|
||||
const { injector } = setup({
|
||||
loss: { kind: "burst", burstProbability: 0, burstLength: 1 },
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.droppedByLoss).toBe(false);
|
||||
});
|
||||
|
||||
it("loss connection-based picks per-edge probability", () => {
|
||||
const { injector } = setup({
|
||||
loss: {
|
||||
kind: "connection-based",
|
||||
probabilityPerEdge: { "a:b": 1, "c:d": 0 },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
injector.inject({ from: "a", to: "b", sizeBytes: 1 }).droppedByLoss
|
||||
).toBe(true);
|
||||
expect(
|
||||
injector.inject({ from: "c", to: "d", sizeBytes: 1 }).droppedByLoss
|
||||
).toBe(false);
|
||||
// Unknown edge defaults to 0
|
||||
expect(
|
||||
injector.inject({ from: "x", to: "y", sizeBytes: 1 }).droppedByLoss
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("bandwidth fixed returns the configured rate", () => {
|
||||
const { injector } = setup({
|
||||
bandwidth: { kind: "fixed", bytesPerSec: 1024 },
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.bandwidthBytesPerSec).toBe(1024);
|
||||
});
|
||||
|
||||
it("bandwidth variable stays within [min, max]", () => {
|
||||
const { injector } = setup({
|
||||
bandwidth: {
|
||||
kind: "variable",
|
||||
minBytesPerSec: 100,
|
||||
maxBytesPerSec: 200,
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.bandwidthBytesPerSec).toBeGreaterThanOrEqual(100);
|
||||
expect(r.bandwidthBytesPerSec).toBeLessThan(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("bandwidth congestion multiplies base by factor", () => {
|
||||
const { injector } = setup({
|
||||
bandwidth: {
|
||||
kind: "congestion",
|
||||
baseBytesPerSec: 1000,
|
||||
congestionFactor: 0.25,
|
||||
},
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.bandwidthBytesPerSec).toBe(250);
|
||||
});
|
||||
|
||||
it("jitter adds amplitude * sin(2π t/period)", () => {
|
||||
const { sim, injector } = setup({
|
||||
latency: { kind: "constant", latencyMs: 10 },
|
||||
jitter: { amplitudeMs: 5, periodMs: 4 },
|
||||
});
|
||||
sim.advanceTo(1); // sin(2π/4) = 1
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.latencyMs).toBeCloseTo(15, 5);
|
||||
});
|
||||
|
||||
it("jitter with periodMs <= 0 contributes zero", () => {
|
||||
const { injector } = setup({
|
||||
latency: { kind: "constant", latencyMs: 10 },
|
||||
jitter: { amplitudeMs: 5, periodMs: 0 },
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.latencyMs).toBe(10);
|
||||
});
|
||||
|
||||
it("disruption: probability=1 immediately drops the edge", () => {
|
||||
const { injector } = setup({
|
||||
disruption: { probability: 1, durationMs: 1_000 },
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.droppedByDisruption).toBe(true);
|
||||
});
|
||||
|
||||
it("disruption: temporary disruption clears after durationMs", () => {
|
||||
const { sim, injector } = setup({
|
||||
disruption: { probability: 1, durationMs: 100 },
|
||||
});
|
||||
injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
sim.advanceBy(150);
|
||||
// After expiry, the edge is no longer disrupted (until next roll)
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
// re-rolled disruption may re-trip with prob 1; either way the
|
||||
// injector executed the expiry path
|
||||
expect(typeof r.droppedByDisruption).toBe("boolean");
|
||||
});
|
||||
|
||||
it("disruption: permanent stays dropped indefinitely", () => {
|
||||
const { sim, injector } = setup({
|
||||
disruption: { probability: 1, durationMs: 100, permanent: true },
|
||||
});
|
||||
injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
sim.advanceBy(10_000);
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.droppedByDisruption).toBe(true);
|
||||
});
|
||||
|
||||
it("update replaces the active config", () => {
|
||||
const { injector } = setup({});
|
||||
injector.update({
|
||||
latency: { kind: "constant", latencyMs: 1 },
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.latencyMs).toBe(1);
|
||||
});
|
||||
|
||||
it("disruption persistence: subsequent inject within window returns dropped", () => {
|
||||
const { sim, injector } = setup({
|
||||
disruption: { probability: 1, durationMs: 1_000 },
|
||||
});
|
||||
injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
sim.advanceBy(100); // still inside the 1_000ms window
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.droppedByDisruption).toBe(true);
|
||||
});
|
||||
|
||||
it("disruption probability=0 never trips", () => {
|
||||
const { injector } = setup({
|
||||
disruption: { probability: 0, durationMs: 1_000 },
|
||||
});
|
||||
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
|
||||
expect(r.droppedByDisruption).toBe(false);
|
||||
});
|
||||
|
||||
it("geographic latency missing toRegion in the model contributes 0", () => {
|
||||
const { injector } = setup({
|
||||
latency: {
|
||||
kind: "geographic",
|
||||
perRegionMs: { "us-east": 30 },
|
||||
},
|
||||
});
|
||||
const r = injector.inject({
|
||||
from: "a",
|
||||
to: "b",
|
||||
fromRegion: "us-east",
|
||||
toRegion: "ap-south", // not in model
|
||||
sizeBytes: 1,
|
||||
});
|
||||
expect(r.latencyMs).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
# `NetworkConditionInjector`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Per-message decision engine that applies the configured `NetworkConditionConfig` (latency / loss / bandwidth / jitter / disruption) to each simulated send. Returns an `InjectionResult` describing the resulting latency, drop verdict, and effective bandwidth so the simulator's transport layer can decide whether and when to deliver the message.
|
||||
|
||||
## Features
|
||||
|
||||
- Three latency models (`constant` / `random` / `geographic`) selected via discriminated `kind`.
|
||||
- Three loss patterns (`random` / `burst` / `connection-based`) — burst is approximated as `burstProbability × burstLength` per attempt for simplicity.
|
||||
- Three bandwidth limits (`fixed` / `variable` / `congestion`).
|
||||
- Sinusoidal jitter added to the resolved latency on every call.
|
||||
- Per-edge disruption tracker remembers active drops until the configured `durationMs` elapses (or forever if `permanent: true`).
|
||||
- Pure: every random decision goes through the simulator's seeded RNG so two runs with identical configs produce identical decisions.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkConditionInjector,
|
||||
NetworkSimulator,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const injector = new NetworkConditionInjector(sim, {
|
||||
latency: { kind: "constant", latencyMs: 50 },
|
||||
loss: { kind: "random", probability: 0.01 },
|
||||
});
|
||||
const result = injector.inject({ from: "n1", to: "n2", sizeBytes: 1024 });
|
||||
result.latencyMs; // 50 + jitter (0 here)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkConditionInjector,
|
||||
NetworkSimulator,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 99 });
|
||||
const injector = new NetworkConditionInjector(sim, {
|
||||
latency: {
|
||||
kind: "geographic",
|
||||
perRegionMs: { "us-east": 30, "eu-west": 80 },
|
||||
},
|
||||
bandwidth: {
|
||||
kind: "congestion",
|
||||
baseBytesPerSec: 5_000_000,
|
||||
congestionFactor: 0.5,
|
||||
},
|
||||
jitter: { amplitudeMs: 20, periodMs: 5_000 },
|
||||
disruption: { probability: 0.001, durationMs: 1_000 },
|
||||
});
|
||||
|
||||
sim.advanceBy(2);
|
||||
const r = injector.inject({
|
||||
from: "n1",
|
||||
to: "n2",
|
||||
fromRegion: "us-east",
|
||||
toRegion: "eu-west",
|
||||
sizeBytes: 4096,
|
||||
});
|
||||
console.log(r.latencyMs, r.bandwidthBytesPerSec);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class NetworkConditionInjector(simulator, config?)`
|
||||
- `update(config: NetworkConditionConfig): void`.
|
||||
- `inject(message: MessageDescriptor): InjectionResult`.
|
||||
- `interface MessageDescriptor` — `{ from, to, fromRegion?, toRegion?, sizeBytes }`.
|
||||
- `interface InjectionResult` — `{ latencyMs, droppedByLoss, droppedByDisruption, dropped, bandwidthBytesPerSec }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All decisions consume one or more values from the simulator's seeded RNG, so byte-identical runs with the same seed produce byte-identical injection results.
|
||||
- Disruption is sticky: once an edge drops, every subsequent inject for that edge returns dropped until either `durationMs` expires or the disruption is `permanent`. This matches the TCP-style "the link is down" experience consumers want to test against.
|
||||
- Geographic latency adds the `fromRegion` and `toRegion` values; missing regions contribute zero so consumers can mix region-tagged and untagged messages without bespoke handling.
|
||||
- The jitter wave uses `sin(2πt/period)` so amplitude is symmetric around zero — large negative jitter can subtract from constant latency, modelling under-load fast paths as well as congested ones.
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import type {
|
||||
BandwidthLimit,
|
||||
DisruptionConfig,
|
||||
JitterConfig,
|
||||
LatencyModel,
|
||||
LossPattern,
|
||||
NetworkConditionConfig,
|
||||
} from "../types/condition.types.js";
|
||||
|
||||
export interface MessageDescriptor {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
readonly fromRegion?: string;
|
||||
readonly toRegion?: string;
|
||||
readonly sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface InjectionResult {
|
||||
readonly latencyMs: number;
|
||||
readonly droppedByLoss: boolean;
|
||||
readonly droppedByDisruption: boolean;
|
||||
/** True if any reason caused the message to drop. */
|
||||
readonly dropped: boolean;
|
||||
readonly bandwidthBytesPerSec: number;
|
||||
}
|
||||
|
||||
interface DisruptionState {
|
||||
readonly until: number;
|
||||
readonly permanent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides per-message latency, drop, and bandwidth based on a
|
||||
* `NetworkConditionConfig` and the simulator's seeded RNG. Stateless
|
||||
* across messages except for the per-edge disruption tracker.
|
||||
*/
|
||||
export class NetworkConditionInjector {
|
||||
private readonly simulator: NetworkSimulator;
|
||||
private readonly disruptions = new Map<string, DisruptionState>();
|
||||
private config: NetworkConditionConfig;
|
||||
|
||||
public constructor(
|
||||
simulator: NetworkSimulator,
|
||||
config: NetworkConditionConfig = {}
|
||||
) {
|
||||
this.simulator = simulator;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public update(config: NetworkConditionConfig): void {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public inject(message: MessageDescriptor): InjectionResult {
|
||||
const latency = this.computeLatency(message);
|
||||
const droppedByLoss = this.computeLoss(message);
|
||||
const droppedByDisruption = this.computeDisruption(message);
|
||||
const bandwidth = this.computeBandwidth();
|
||||
return {
|
||||
latencyMs: latency + this.computeJitter(),
|
||||
droppedByLoss,
|
||||
droppedByDisruption,
|
||||
dropped: droppedByLoss || droppedByDisruption,
|
||||
bandwidthBytesPerSec: bandwidth,
|
||||
};
|
||||
}
|
||||
|
||||
private computeLatency(message: MessageDescriptor): number {
|
||||
const model = this.config.latency;
|
||||
if (model === undefined) return 0;
|
||||
return resolveLatency(model, message, this.simulator);
|
||||
}
|
||||
|
||||
private computeLoss(message: MessageDescriptor): boolean {
|
||||
const pattern = this.config.loss;
|
||||
if (pattern === undefined) return false;
|
||||
return resolveLoss(pattern, message, this.simulator);
|
||||
}
|
||||
|
||||
private computeBandwidth(): number {
|
||||
const limit = this.config.bandwidth;
|
||||
if (limit === undefined) return Number.POSITIVE_INFINITY;
|
||||
return resolveBandwidth(limit, this.simulator);
|
||||
}
|
||||
|
||||
private computeJitter(): number {
|
||||
const j = this.config.jitter;
|
||||
if (j === undefined) return 0;
|
||||
return resolveJitter(j, this.simulator.now());
|
||||
}
|
||||
|
||||
private computeDisruption(message: MessageDescriptor): boolean {
|
||||
const cfg = this.config.disruption;
|
||||
if (cfg === undefined) return false;
|
||||
const key = `${message.from}:${message.to}`;
|
||||
const existing = this.disruptions.get(key);
|
||||
const now = this.simulator.now();
|
||||
if (existing !== undefined) {
|
||||
if (existing.permanent) return true;
|
||||
if (now < existing.until) return true;
|
||||
this.disruptions.delete(key);
|
||||
}
|
||||
return maybeEnterDisruption(
|
||||
cfg,
|
||||
key,
|
||||
this.disruptions,
|
||||
now,
|
||||
this.simulator
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLatency(
|
||||
model: LatencyModel,
|
||||
message: MessageDescriptor,
|
||||
sim: NetworkSimulator
|
||||
): number {
|
||||
switch (model.kind) {
|
||||
case "constant":
|
||||
return model.latencyMs;
|
||||
case "random":
|
||||
return model.minMs + sim.rand() * (model.maxMs - model.minMs);
|
||||
case "geographic": {
|
||||
const fromMs =
|
||||
message.fromRegion !== undefined
|
||||
? (model.perRegionMs[message.fromRegion] ?? 0)
|
||||
: 0;
|
||||
const toMs =
|
||||
message.toRegion !== undefined
|
||||
? (model.perRegionMs[message.toRegion] ?? 0)
|
||||
: 0;
|
||||
return fromMs + toMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLoss(
|
||||
pattern: LossPattern,
|
||||
message: MessageDescriptor,
|
||||
sim: NetworkSimulator
|
||||
): boolean {
|
||||
switch (pattern.kind) {
|
||||
case "random":
|
||||
return sim.rand() < pattern.probability;
|
||||
case "burst": {
|
||||
// Treat burstLength as additional drop probability while a
|
||||
// burst is "active" — kept simple: a burst trip raises the
|
||||
// effective probability for `burstLength` attempts.
|
||||
return sim.rand() < pattern.burstProbability * pattern.burstLength;
|
||||
}
|
||||
case "connection-based": {
|
||||
const key = `${message.from}:${message.to}`;
|
||||
const probability = pattern.probabilityPerEdge[key] ?? 0;
|
||||
return sim.rand() < probability;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBandwidth(
|
||||
limit: BandwidthLimit,
|
||||
sim: NetworkSimulator
|
||||
): number {
|
||||
switch (limit.kind) {
|
||||
case "fixed":
|
||||
return limit.bytesPerSec;
|
||||
case "variable":
|
||||
return (
|
||||
limit.minBytesPerSec +
|
||||
sim.rand() * (limit.maxBytesPerSec - limit.minBytesPerSec)
|
||||
);
|
||||
case "congestion":
|
||||
return limit.baseBytesPerSec * limit.congestionFactor;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveJitter(j: JitterConfig, nowMs: number): number {
|
||||
if (j.periodMs <= 0) return 0;
|
||||
const phase = (2 * Math.PI * nowMs) / j.periodMs;
|
||||
return j.amplitudeMs * Math.sin(phase);
|
||||
}
|
||||
|
||||
function maybeEnterDisruption(
|
||||
cfg: DisruptionConfig,
|
||||
key: string,
|
||||
map: Map<string, DisruptionState>,
|
||||
nowMs: number,
|
||||
sim: NetworkSimulator
|
||||
): boolean {
|
||||
if (sim.rand() >= cfg.probability) return false;
|
||||
map.set(key, {
|
||||
until: nowMs + cfg.durationMs,
|
||||
permanent: cfg.permanent === true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Error codes — `@suaveplan/network-plugin-simulation`
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` 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 consumers branch on `error.code` without parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- Four codes covering topology validation, node lookup, scenario validation, and time-control validation. The previous registry shipped with `CRITERION_FAILED` reserved for predefined-scenario integration code; the placeholder was removed because the current dispatcher reports criterion failures inline via the `ScenarioResult.criteriaResults` map rather than throwing on the failure path.
|
||||
- `NetworkSimError` extends `SuaveplanError<NetworkSimErrorCode>` for cause-chain traversal, redaction, and OTel adapter compatibility.
|
||||
- Frozen registry — runtime mutation throws.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_SIM_CODES,
|
||||
NetworkSimError,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.NODE_NOT_FOUND,
|
||||
"simulator.unknown-node",
|
||||
{ context: { nodeId: "n42" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
try {
|
||||
runner.run(scenario);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof NetworkSimError &&
|
||||
err.code === NETWORK_SIM_CODES.SCENARIO_INVALID
|
||||
) {
|
||||
return reportInvalidScenario(scenario);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_SIM_CODES` — frozen `KEY -> "NET_SIM.KEY"` map.
|
||||
- `NetworkSimErrorCode` — TypeScript union of every code value.
|
||||
- `NetworkSimError` — `SuaveplanError<NetworkSimErrorCode>` subclass; `name === "NetworkSimError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is constructed via `defineCodes("NET_SIM", […] as const)` so the type system enumerates the value union; adding a code requires bumping the `as const` tuple.
|
||||
- Codes are deliberately coarse (one per failure class) so consumers build stable retry / observability policies without churn.
|
||||
- `TIME_INVALID` covers both negative time-factors and `advanceBy(Δ)` calls with non-positive deltas; the messageKey distinguishes the two situations.
|
||||
- Predefined-scenario integration code (`server-failure`, `partition`, `client-surge`, `cascading-failure` modules) ships in follow-up changes and may add its own codes there.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_SIM_CODES, NetworkSimError } from "./error-codes.js";
|
||||
|
||||
describe("NETWORK_SIM_CODES", () => {
|
||||
it("registers under NET_SIM", () => {
|
||||
for (const value of Object.values(NETWORK_SIM_CODES)) {
|
||||
expect(value).toMatch(/^NET_SIM\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(NETWORK_SIM_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
for (const key of [
|
||||
"TOPOLOGY_INVALID",
|
||||
"NODE_NOT_FOUND",
|
||||
"SCENARIO_INVALID",
|
||||
"TIME_INVALID",
|
||||
] as const) {
|
||||
expect(NETWORK_SIM_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkSimError", () => {
|
||||
it("extends SuaveplanError", () => {
|
||||
const err = new NetworkSimError(
|
||||
NETWORK_SIM_CODES.TOPOLOGY_INVALID,
|
||||
"x"
|
||||
);
|
||||
expect(err.name).toBe("NetworkSimError");
|
||||
expect(err.code).toBe(NETWORK_SIM_CODES.TOPOLOGY_INVALID);
|
||||
});
|
||||
|
||||
it("preserves cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new NetworkSimError(
|
||||
NETWORK_SIM_CODES.TIME_INVALID,
|
||||
"with-cause",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
export const NETWORK_SIM_CODES = defineCodes("NET_SIM", [
|
||||
"TOPOLOGY_INVALID",
|
||||
"NODE_NOT_FOUND",
|
||||
"SCENARIO_INVALID",
|
||||
"TIME_INVALID",
|
||||
] as const);
|
||||
|
||||
export type NetworkSimErrorCode =
|
||||
(typeof NETWORK_SIM_CODES)[keyof typeof NETWORK_SIM_CODES];
|
||||
|
||||
export class NetworkSimError extends SuaveplanError<NetworkSimErrorCode> {
|
||||
public constructor(
|
||||
code: NetworkSimErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetworkSimError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Error codes (side-effect registration)
|
||||
import "./error-codes.js";
|
||||
|
||||
// Chaos
|
||||
export {
|
||||
type ChaosConfig,
|
||||
ChaosEngine,
|
||||
type ChaosEvent,
|
||||
} from "./chaos/chaos-engine.js";
|
||||
// Conditions
|
||||
export {
|
||||
type InjectionResult,
|
||||
type MessageDescriptor,
|
||||
NetworkConditionInjector,
|
||||
} from "./conditions/network-condition-injector.js";
|
||||
// Errors
|
||||
export {
|
||||
NETWORK_SIM_CODES,
|
||||
NetworkSimError,
|
||||
type NetworkSimErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Plugin
|
||||
export {
|
||||
SimulationPlugin,
|
||||
type SimulationPluginConfig,
|
||||
} from "./plugin/simulation-plugin.js";
|
||||
// Scenario
|
||||
export {
|
||||
type EventHandler,
|
||||
ScenarioRunner,
|
||||
type ScenarioRunnerConfig,
|
||||
validateScenario,
|
||||
} from "./scenario/scenario-runner.js";
|
||||
// Simulator
|
||||
export {
|
||||
mulberry32,
|
||||
NetworkSimulator,
|
||||
type VirtualEdge,
|
||||
type VirtualTopology,
|
||||
} from "./simulator/network-simulator.js";
|
||||
// Types
|
||||
export type {
|
||||
BandwidthLimit,
|
||||
DisruptionConfig,
|
||||
JitterConfig,
|
||||
LatencyModel,
|
||||
LossPattern,
|
||||
NetworkConditionConfig,
|
||||
} from "./types/condition.types.js";
|
||||
export type {
|
||||
ScenarioCheckpoint,
|
||||
ScenarioEvent,
|
||||
ScenarioEventType,
|
||||
ScenarioResult,
|
||||
SimulationScenario,
|
||||
SuccessCriterion,
|
||||
} from "./types/scenario.types.js";
|
||||
export type {
|
||||
NetworkTopologyConfig,
|
||||
SimulationConfig,
|
||||
TopologyKind,
|
||||
VirtualNodeConfig,
|
||||
} from "./types/simulation.types.js";
|
||||
// Virtual node
|
||||
export {
|
||||
VirtualNode,
|
||||
type VirtualNodeEvent,
|
||||
type VirtualNodeStatus,
|
||||
} from "./virtual-node/virtual-node.js";
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SimulationPlugin } from "./simulation-plugin.js";
|
||||
|
||||
const newNode = () =>
|
||||
new NetworkNode({ id: "node-1", networkBus: new NetworkBus() });
|
||||
|
||||
describe("SimulationPlugin: lifecycle + production no-op", () => {
|
||||
it("constructs with simulator + conditions + chaos", () => {
|
||||
const plugin = new SimulationPlugin({
|
||||
simulation: { seed: 1 },
|
||||
disabled: false,
|
||||
});
|
||||
expect(plugin.name).toBe("simulation");
|
||||
expect(plugin.simulator).toBeDefined();
|
||||
expect(plugin.conditions).toBeDefined();
|
||||
expect(plugin.chaos).toBeDefined();
|
||||
});
|
||||
|
||||
it("install + start + stop + dispose lifecycle when active", async () => {
|
||||
const plugin = new SimulationPlugin({
|
||||
simulation: { seed: 1 },
|
||||
disabled: false,
|
||||
});
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.host()).not.toBeNull();
|
||||
expect(plugin.isStarted()).toBe(true);
|
||||
await node.stop();
|
||||
expect(plugin.isStarted()).toBe(false);
|
||||
plugin.dispose();
|
||||
expect(plugin.host()).toBeNull();
|
||||
});
|
||||
|
||||
it("disabled plugin skips install + start (no-op)", async () => {
|
||||
const plugin = new SimulationPlugin({
|
||||
simulation: { seed: 1 },
|
||||
disabled: true,
|
||||
});
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.host()).toBeNull();
|
||||
expect(plugin.isStarted()).toBe(false);
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("isActive reports the disabled state", () => {
|
||||
expect(
|
||||
new SimulationPlugin({
|
||||
simulation: { seed: 1 },
|
||||
disabled: true,
|
||||
}).isActive()
|
||||
).toBe(false);
|
||||
expect(
|
||||
new SimulationPlugin({
|
||||
simulation: { seed: 1 },
|
||||
disabled: false,
|
||||
}).isActive()
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("subsystems remain usable even when the plugin is disabled", () => {
|
||||
const plugin = new SimulationPlugin({
|
||||
simulation: { seed: 1 },
|
||||
disabled: true,
|
||||
});
|
||||
plugin.simulator.advanceBy(10);
|
||||
expect(plugin.simulator.now()).toBe(10);
|
||||
});
|
||||
|
||||
it("dispose clears the chaos event log", () => {
|
||||
const plugin = new SimulationPlugin({
|
||||
simulation: { seed: 1 },
|
||||
chaos: { messageDropProbability: 1 },
|
||||
disabled: false,
|
||||
});
|
||||
plugin.chaos.step([], []);
|
||||
plugin.dispose();
|
||||
expect(plugin.chaos.eventLog().length).toBe(0);
|
||||
});
|
||||
|
||||
it("default disabled defers to NODE_ENV", () => {
|
||||
const previous = process.env["NODE_ENV"];
|
||||
try {
|
||||
process.env["NODE_ENV"] = "production";
|
||||
const a = new SimulationPlugin({ simulation: { seed: 1 } });
|
||||
expect(a.isActive()).toBe(false);
|
||||
process.env["NODE_ENV"] = "development";
|
||||
const b = new SimulationPlugin({ simulation: { seed: 1 } });
|
||||
expect(b.isActive()).toBe(true);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env["NODE_ENV"];
|
||||
} else {
|
||||
process.env["NODE_ENV"] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# `SimulationPlugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single `NetworkPlugin` that bundles the simulation toolkit (`NetworkSimulator`, `NetworkConditionInjector`, `ChaosEngine`) and ties their lifecycle to a host `NetworkNode`. Every subsystem is exposed as a public field so consumers extend behaviour by reaching past the plugin facade. No-op in production builds so accidentally bundling the plugin into a release does not run chaos against real peers.
|
||||
|
||||
## Features
|
||||
|
||||
- `disabled` defaults to `process.env.NODE_ENV === "production"`; explicit `disabled: false` overrides for staging environments.
|
||||
- `install` / `start` short-circuit when disabled — the plugin remains constructed but inert.
|
||||
- `dispose` resets the chaos event log so a re-install starts clean.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import { SimulationPlugin } from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const node = new NetworkNode({ id: "n", networkBus: new NetworkBus() });
|
||||
const plugin = new SimulationPlugin({ simulation: { seed: 1 } });
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
plugin.simulator.advanceBy(10);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SimulationPlugin } from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const plugin = new SimulationPlugin({
|
||||
simulation: { seed: 42, timeFactor: 1_000 },
|
||||
conditions: {
|
||||
latency: { kind: "constant", latencyMs: 50 },
|
||||
loss: { kind: "random", probability: 0.01 },
|
||||
},
|
||||
chaos: { crashProbability: 0.001 },
|
||||
disabled: false,
|
||||
});
|
||||
plugin.isActive(); // true
|
||||
plugin.simulator.now();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SimulationPlugin implements NetworkPlugin`
|
||||
- Public fields: `simulator`, `conditions`, `chaos`.
|
||||
- `constructor(config: SimulationPluginConfig)`.
|
||||
- `isActive(): boolean`.
|
||||
- `install(node): void` / `start(): void` / `stop(): void` / `dispose(): void`.
|
||||
- `host(): NetworkNode | null` / `isStarted(): boolean`.
|
||||
- `interface SimulationPluginConfig` — `{ simulation, conditions?, chaos?, disabled? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The plugin holds _references_ to subsystems rather than re-exporting their methods so refactors inside any subsystem do not reshape the plugin facade.
|
||||
- The default-disabled-in-production guard reads `process.env.NODE_ENV` defensively (`process` may be undefined in the browser); browser callers wanting simulation explicitly pass `disabled: false`.
|
||||
- `install` is the only method that gates on `disabled`; the simulator / conditions / chaos fields remain non-null and usable so test code can still exercise them via `plugin.simulator.advanceBy(...)` even while the plugin is "disabled".
|
||||
- The four predefined integration scenarios (`server-failure`, `network-partition`, `client-surge`, `cascading-failure`) ship as follow-up changes — they require coordination with `failover`, `topology`, `circuit-breaker`, etc., which lives outside this package's contract.
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
|
||||
import { type ChaosConfig, ChaosEngine } from "../chaos/chaos-engine.js";
|
||||
import { NetworkConditionInjector } from "../conditions/network-condition-injector.js";
|
||||
import { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import type { NetworkConditionConfig } from "../types/condition.types.js";
|
||||
import type { SimulationConfig } from "../types/simulation.types.js";
|
||||
|
||||
export interface SimulationPluginConfig {
|
||||
readonly simulation: SimulationConfig;
|
||||
readonly conditions?: NetworkConditionConfig;
|
||||
readonly chaos?: ChaosConfig;
|
||||
/**
|
||||
* When true, the plugin behaves as a no-op (`isActive: false`).
|
||||
* Defaults to `process.env.NODE_ENV === "production"` so production
|
||||
* builds never accidentally run simulation work.
|
||||
*/
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `NetworkPlugin` that exposes the simulation toolkit
|
||||
* (`NetworkSimulator`, `NetworkConditionInjector`, `ChaosEngine`) as
|
||||
* public fields. No-op in production unless `disabled: false` is
|
||||
* passed explicitly.
|
||||
*/
|
||||
export class SimulationPlugin implements NetworkPlugin {
|
||||
public readonly name = "simulation";
|
||||
public readonly simulator: NetworkSimulator;
|
||||
public readonly conditions: NetworkConditionInjector;
|
||||
public readonly chaos: ChaosEngine;
|
||||
private readonly disabled: boolean;
|
||||
private node: NetworkNode | null = null;
|
||||
private started = false;
|
||||
|
||||
public constructor(config: SimulationPluginConfig) {
|
||||
this.simulator = new NetworkSimulator(config.simulation);
|
||||
this.conditions = new NetworkConditionInjector(
|
||||
this.simulator,
|
||||
config.conditions
|
||||
);
|
||||
this.chaos = new ChaosEngine(this.simulator, config.chaos);
|
||||
this.disabled =
|
||||
config.disabled ??
|
||||
(typeof process !== "undefined" &&
|
||||
process.env?.["NODE_ENV"] === "production");
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return !this.disabled;
|
||||
}
|
||||
|
||||
public install(node: NetworkNode): void {
|
||||
if (this.disabled) return;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.disabled) return;
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.chaos.reset();
|
||||
this.node = null;
|
||||
}
|
||||
|
||||
public host(): NetworkNode | null {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
public isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# `ScenarioRunner`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Executes a declarative `SimulationScenario` against a `NetworkSimulator`. Sorts events by `timeMs`, advances the simulator clock to each event, dispatches the event to a caller-supplied handler, and records checkpoints + final criterion evaluation. The handler is the boundary where consumers wire scenario events to virtual-node failures, condition changes, and chaos injection.
|
||||
|
||||
## Features
|
||||
|
||||
- Time-sorted event execution so authors can declare events in any order.
|
||||
- Per-criterion `evaluateAtCheckpoints` opt-in records criteria mid-run for debugging.
|
||||
- Metric collection via `bumpMetric` / `setMetric` so handlers report counts / averages back into the result.
|
||||
- `validateScenario` exposed as a free helper for pre-flight CI checks.
|
||||
- Seed cross-check between scenario and simulator so determinism is enforced before any work runs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkSimulator,
|
||||
ScenarioRunner,
|
||||
type SimulationScenario,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: (event) => console.log("event", event.type, event.timeMs),
|
||||
});
|
||||
|
||||
const scenario: SimulationScenario = {
|
||||
name: "smoke",
|
||||
seed: 1,
|
||||
initialTopology: { kind: "mesh", nodes: [{ id: "n1", tier: 0 }] },
|
||||
nodeCount: 1,
|
||||
events: [{ timeMs: 100, type: "fail", targetPeerIds: ["n1"] }],
|
||||
successCriteria: [],
|
||||
};
|
||||
const result = runner.run(scenario);
|
||||
result.succeeded; // true (no criteria)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkSimulator,
|
||||
ScenarioRunner,
|
||||
validateScenario,
|
||||
type SimulationScenario,
|
||||
type SuccessCriterion,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 7 });
|
||||
const allEventsRan: SuccessCriterion = {
|
||||
id: "ran",
|
||||
description: "scenario reached completion",
|
||||
evaluate: () => true,
|
||||
evaluateAtCheckpoints: true,
|
||||
};
|
||||
|
||||
const scenario: SimulationScenario = {
|
||||
name: "checkpointed",
|
||||
seed: 7,
|
||||
initialTopology: { kind: "star", nodes: [{ id: "h", tier: 0 }, { id: "s", tier: 1 }] },
|
||||
nodeCount: 2,
|
||||
events: [
|
||||
{ timeMs: 100, type: "join", targetPeerIds: ["s"] },
|
||||
{ timeMs: 200, type: "checkpoint", label: "after-join" },
|
||||
{ timeMs: 300, type: "leave", targetPeerIds: ["s"] },
|
||||
],
|
||||
successCriteria: [allEventsRan],
|
||||
};
|
||||
validateScenario(scenario);
|
||||
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: (event) => runner.bumpMetric(`event.${event.type}`),
|
||||
});
|
||||
const result = runner.run(scenario);
|
||||
result.checkpoints[0]?.label; // "after-join"
|
||||
result.metrics["event.join"]; // 1
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ScenarioRunner(config: ScenarioRunnerConfig)`
|
||||
- `bumpMetric(name, delta?): void`.
|
||||
- `setMetric(name, value): void`.
|
||||
- `run(scenario: SimulationScenario): ScenarioResult`.
|
||||
- `function validateScenario(scenario): void` — throws `SCENARIO_INVALID` for empty name / negative node count / duplicate criterion id.
|
||||
- `interface ScenarioRunnerConfig` — `{ simulator, handler }`.
|
||||
- `type EventHandler` — `(event: ScenarioEvent) => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Events with `type: "checkpoint"` are NOT dispatched to the handler — the runner records the snapshot directly. Other event types always reach the handler.
|
||||
- The runner advances time _to_ each event's `timeMs` (no-op when the simulator is already at or past it). This keeps timing deterministic when scenarios mix close-together and far-apart events.
|
||||
- Final-run criteria are always evaluated, regardless of `evaluateAtCheckpoints`. The flag only controls whether mid-run snapshots include the criterion.
|
||||
- `metrics` and `criteriaResults` are frozen on the returned result so consumers cannot mutate the report after the fact.
|
||||
@@ -0,0 +1,154 @@
|
||||
import { NETWORK_SIM_CODES, NetworkSimError } from "../error-codes.js";
|
||||
import type { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import type {
|
||||
ScenarioCheckpoint,
|
||||
ScenarioEvent,
|
||||
ScenarioResult,
|
||||
SimulationScenario,
|
||||
SuccessCriterion,
|
||||
} from "../types/scenario.types.js";
|
||||
|
||||
export type EventHandler = (event: ScenarioEvent) => void;
|
||||
|
||||
export interface ScenarioRunnerConfig {
|
||||
readonly simulator: NetworkSimulator;
|
||||
/**
|
||||
* Per-event handler the runner calls when each scheduled event
|
||||
* fires. Consumers wire this to apply join / leave / fail / etc.
|
||||
* against virtual nodes / chaos engine / condition injector.
|
||||
*/
|
||||
readonly handler: EventHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a `SimulationScenario`. Sorts events by `timeMs`, advances
|
||||
* the simulator to each event's time, fires the handler, and records
|
||||
* checkpoints / final criterion evaluation.
|
||||
*/
|
||||
export class ScenarioRunner {
|
||||
private readonly simulator: NetworkSimulator;
|
||||
private readonly handler: EventHandler;
|
||||
private readonly metrics = new Map<string, number>();
|
||||
|
||||
public constructor(config: ScenarioRunnerConfig) {
|
||||
this.simulator = config.simulator;
|
||||
this.handler = config.handler;
|
||||
}
|
||||
|
||||
public bumpMetric(name: string, delta = 1): void {
|
||||
this.metrics.set(name, (this.metrics.get(name) ?? 0) + delta);
|
||||
}
|
||||
|
||||
public setMetric(name: string, value: number): void {
|
||||
this.metrics.set(name, value);
|
||||
}
|
||||
|
||||
public run(scenario: SimulationScenario): ScenarioResult {
|
||||
validateScenario(scenario);
|
||||
if (scenario.seed !== this.simulator.getSeed()) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.SCENARIO_INVALID,
|
||||
"scenario.seed-mismatch",
|
||||
{
|
||||
context: {
|
||||
scenarioSeed: scenario.seed,
|
||||
simulatorSeed: this.simulator.getSeed(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const sortedEvents = [...scenario.events].sort(
|
||||
(a, b) => a.timeMs - b.timeMs
|
||||
);
|
||||
const checkpoints: ScenarioCheckpoint[] = [];
|
||||
const executed: ScenarioEvent[] = [];
|
||||
for (const event of sortedEvents) {
|
||||
if (event.timeMs > this.simulator.now()) {
|
||||
this.simulator.advanceTo(event.timeMs);
|
||||
}
|
||||
if (event.type === "checkpoint") {
|
||||
checkpoints.push(this.snapshotCheckpoint(scenario, event));
|
||||
executed.push(event);
|
||||
continue;
|
||||
}
|
||||
this.handler(event);
|
||||
executed.push(event);
|
||||
}
|
||||
const criteriaResults: Record<string, boolean> = {};
|
||||
let succeeded = true;
|
||||
for (const criterion of scenario.successCriteria) {
|
||||
const result = criterion.evaluate({
|
||||
simulatedTimeMs: this.simulator.now(),
|
||||
});
|
||||
criteriaResults[criterion.id] = result;
|
||||
if (!result) succeeded = false;
|
||||
}
|
||||
return Object.freeze({
|
||||
scenario: scenario.name,
|
||||
succeeded,
|
||||
checkpoints,
|
||||
criteriaResults,
|
||||
metrics: Object.freeze(Object.fromEntries(this.metrics)),
|
||||
executedEvents: executed,
|
||||
});
|
||||
}
|
||||
|
||||
private snapshotCheckpoint(
|
||||
scenario: SimulationScenario,
|
||||
event: ScenarioEvent
|
||||
): ScenarioCheckpoint {
|
||||
const snapshot: Record<string, boolean> = {};
|
||||
for (const criterion of scenario.successCriteria) {
|
||||
if (criterion.evaluateAtCheckpoints !== true) continue;
|
||||
snapshot[criterion.id] = criterion.evaluate({
|
||||
simulatedTimeMs: this.simulator.now(),
|
||||
});
|
||||
}
|
||||
return event.label !== undefined
|
||||
? {
|
||||
atMs: this.simulator.now(),
|
||||
label: event.label,
|
||||
criteriaResults: snapshot,
|
||||
}
|
||||
: { atMs: this.simulator.now(), criteriaResults: snapshot };
|
||||
}
|
||||
}
|
||||
|
||||
export function validateScenario(scenario: SimulationScenario): void {
|
||||
if (typeof scenario.name !== "string" || scenario.name.length === 0) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.SCENARIO_INVALID,
|
||||
"scenario.empty-name"
|
||||
);
|
||||
}
|
||||
if (scenario.nodeCount < 0) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.SCENARIO_INVALID,
|
||||
"scenario.node-count-negative",
|
||||
{ context: { nodeCount: scenario.nodeCount } }
|
||||
);
|
||||
}
|
||||
for (const criterion of scenario.successCriteria) {
|
||||
if (typeof criterion.id !== "string" || criterion.id.length === 0) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.SCENARIO_INVALID,
|
||||
"scenario.criterion-id-empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
seenIdsUnique(scenario.successCriteria);
|
||||
}
|
||||
|
||||
function seenIdsUnique(criteria: ReadonlyArray<SuccessCriterion>): void {
|
||||
const seen = new Set<string>();
|
||||
for (const c of criteria) {
|
||||
if (seen.has(c.id)) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.SCENARIO_INVALID,
|
||||
"scenario.criterion-duplicate-id",
|
||||
{ context: { id: c.id } }
|
||||
);
|
||||
}
|
||||
seen.add(c.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_SIM_CODES, type NetworkSimError } from "../error-codes.js";
|
||||
import { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import type {
|
||||
ScenarioEvent,
|
||||
SimulationScenario,
|
||||
SuccessCriterion,
|
||||
} from "../types/scenario.types.js";
|
||||
import { ScenarioRunner, validateScenario } from "./scenario-runner.js";
|
||||
|
||||
const baseScenario = (
|
||||
overrides: Partial<SimulationScenario> = {}
|
||||
): SimulationScenario => ({
|
||||
name: "test",
|
||||
seed: 1,
|
||||
initialTopology: {
|
||||
kind: "mesh",
|
||||
nodes: [{ id: "n1", tier: 0 }],
|
||||
},
|
||||
nodeCount: 1,
|
||||
events: [],
|
||||
successCriteria: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ScenarioRunner: execution + criteria + checkpoints + metrics", () => {
|
||||
it("runs events in time order regardless of declaration order", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const fired: string[] = [];
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: (event) => fired.push(`${event.timeMs}:${event.type}`),
|
||||
});
|
||||
const scenario = baseScenario({
|
||||
events: [
|
||||
{ timeMs: 200, type: "leave", targetPeerIds: ["n1"] },
|
||||
{ timeMs: 100, type: "join", targetPeerIds: ["n1"] },
|
||||
],
|
||||
});
|
||||
runner.run(scenario);
|
||||
expect(fired).toEqual(["100:join", "200:leave"]);
|
||||
});
|
||||
|
||||
it("evaluates success criteria at scenario end", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: () => undefined,
|
||||
});
|
||||
const passing: SuccessCriterion = {
|
||||
id: "c1",
|
||||
description: "always",
|
||||
evaluate: () => true,
|
||||
};
|
||||
const failing: SuccessCriterion = {
|
||||
id: "c2",
|
||||
description: "never",
|
||||
evaluate: () => false,
|
||||
};
|
||||
const result = runner.run(
|
||||
baseScenario({ successCriteria: [passing, failing] })
|
||||
);
|
||||
expect(result.criteriaResults).toEqual({ c1: true, c2: false });
|
||||
expect(result.succeeded).toBe(false);
|
||||
});
|
||||
|
||||
it("checkpoint events snapshot opted-in criteria mid-run", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: () => undefined,
|
||||
});
|
||||
const criterion: SuccessCriterion = {
|
||||
id: "wall",
|
||||
description: "atMs increments",
|
||||
evaluate: ({ simulatedTimeMs }) => simulatedTimeMs >= 100,
|
||||
evaluateAtCheckpoints: true,
|
||||
};
|
||||
const result = runner.run(
|
||||
baseScenario({
|
||||
events: [
|
||||
{ timeMs: 100, type: "checkpoint", label: "after-100" },
|
||||
{
|
||||
timeMs: 50,
|
||||
type: "checkpoint",
|
||||
label: "before-100",
|
||||
},
|
||||
],
|
||||
successCriteria: [criterion],
|
||||
})
|
||||
);
|
||||
expect(result.checkpoints.length).toBe(2);
|
||||
const before = result.checkpoints.find((c) => c.label === "before-100");
|
||||
const after = result.checkpoints.find((c) => c.label === "after-100");
|
||||
expect(before?.criteriaResults["wall"]).toBe(false);
|
||||
expect(after?.criteriaResults["wall"]).toBe(true);
|
||||
});
|
||||
|
||||
it("checkpoint without label and without opted-in criteria still records", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: () => undefined,
|
||||
});
|
||||
const result = runner.run(
|
||||
baseScenario({
|
||||
events: [{ timeMs: 50, type: "checkpoint" }],
|
||||
successCriteria: [
|
||||
{
|
||||
id: "x",
|
||||
description: "y",
|
||||
evaluate: () => true,
|
||||
// not opted-in to checkpoints
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(result.checkpoints.length).toBe(1);
|
||||
expect(result.checkpoints[0]?.criteriaResults).toEqual({});
|
||||
});
|
||||
|
||||
it("metrics: bumpMetric and setMetric appear in result", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: (event) => runner.bumpMetric(`event.${event.type}`),
|
||||
});
|
||||
runner.setMetric("constant", 42);
|
||||
const result = runner.run(
|
||||
baseScenario({
|
||||
events: [
|
||||
{ timeMs: 10, type: "join", targetPeerIds: ["n1"] },
|
||||
{ timeMs: 20, type: "leave", targetPeerIds: ["n1"] },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(result.metrics["event.join"]).toBe(1);
|
||||
expect(result.metrics["event.leave"]).toBe(1);
|
||||
expect(result.metrics["constant"]).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects scenario with mismatched seed", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: () => undefined,
|
||||
});
|
||||
try {
|
||||
runner.run(baseScenario({ seed: 99 }));
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkSimError).code).toBe(
|
||||
NETWORK_SIM_CODES.SCENARIO_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("validateScenario rejects empty name / negative count / duplicate criteria", () => {
|
||||
for (const bad of [
|
||||
baseScenario({ name: "" }),
|
||||
baseScenario({ nodeCount: -1 }),
|
||||
baseScenario({
|
||||
successCriteria: [
|
||||
{ id: "x", description: "a", evaluate: () => true },
|
||||
{ id: "x", description: "b", evaluate: () => true },
|
||||
],
|
||||
}),
|
||||
baseScenario({
|
||||
successCriteria: [
|
||||
{ id: "", description: "x", evaluate: () => true },
|
||||
],
|
||||
}),
|
||||
]) {
|
||||
try {
|
||||
validateScenario(bad);
|
||||
expect.fail(`should have thrown for ${bad.name}`);
|
||||
} catch (err) {
|
||||
expect((err as NetworkSimError).code).toBe(
|
||||
NETWORK_SIM_CODES.SCENARIO_INVALID
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not advance backwards when an event is at or before current time", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const fired: number[] = [];
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: () => fired.push(sim.now()),
|
||||
});
|
||||
runner.run(
|
||||
baseScenario({
|
||||
events: [
|
||||
{ timeMs: 0, type: "join" }, // at-or-before now
|
||||
{ timeMs: 0, type: "leave" }, // also at now
|
||||
],
|
||||
})
|
||||
);
|
||||
// Both fire at simulated time 0
|
||||
expect(fired).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("emits executedEvents in time order", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const runner = new ScenarioRunner({
|
||||
simulator: sim,
|
||||
handler: () => undefined,
|
||||
});
|
||||
const events: ScenarioEvent[] = [
|
||||
{ timeMs: 200, type: "leave" },
|
||||
{ timeMs: 100, type: "join" },
|
||||
];
|
||||
const result = runner.run(baseScenario({ events }));
|
||||
expect(result.executedEvents.map((e) => e.timeMs)).toEqual([100, 200]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
# `NetworkSimulator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Deterministic, time-controlled simulation core. Owns the seeded Mulberry32 PRNG, the simulated wall clock (with a configurable `timeFactor` multiplier), and the four built-in topology factories (star / mesh / tiered / custom). Every other module in the package — `VirtualNode`, `ScenarioRunner`, `NetworkConditionInjector`, `ChaosEngine` — consumes a single `NetworkSimulator` instance so timing and randomness stay coordinated.
|
||||
|
||||
## Features
|
||||
|
||||
- 32-bit Mulberry32 PRNG seeded from `config.seed` so identical configs produce byte-identical runs.
|
||||
- `advanceBy(wallMs)` multiplies by `timeFactor`; `advanceTo(absMs)` sets the simulated clock directly (rejects backward jumps).
|
||||
- `randInt(min, max)` convenience helper for integer ranges.
|
||||
- Four topology factories (`star` / `mesh` / `tiered` / `custom`) returning normalised `VirtualTopology` `{ nodes, edges }`.
|
||||
- All time / topology validation throws typed `NetworkSimError`s so caller bugs fail loudly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkSimulator } from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 42, timeFactor: 100 });
|
||||
sim.now(); // 0
|
||||
sim.advanceBy(10); // 1000 ms simulated (10 wall * 100 factor)
|
||||
sim.rand(); // deterministic float in [0,1)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkSimulator,
|
||||
type NetworkTopologyConfig,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 12345, timeFactor: 1000 });
|
||||
|
||||
const topology: NetworkTopologyConfig = {
|
||||
kind: "tiered",
|
||||
nodes: [
|
||||
{ id: "h1", tier: 0 },
|
||||
{ id: "h2", tier: 0 },
|
||||
{ id: "s1", tier: 1 },
|
||||
{ id: "s2", tier: 1 },
|
||||
{ id: "l1", tier: 2 },
|
||||
],
|
||||
};
|
||||
const built = sim.buildTopology(topology);
|
||||
console.log(built.edges.length); // 8 — every tier-0 paired with every tier-1, every tier-1 with every tier-2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class NetworkSimulator(config: SimulationConfig)`
|
||||
- `getSeed(): number`.
|
||||
- `now(): number` — simulated time.
|
||||
- `timeFactorValue(): number`.
|
||||
- `advanceBy(wallMs): number` — throws `TIME_INVALID` for non-positive deltas.
|
||||
- `advanceTo(absoluteSimulatedMs): number` — throws `TIME_INVALID` for backward jumps.
|
||||
- `rand(): number` — `[0, 1)` deterministic.
|
||||
- `randInt(min, max): number` — `[min, max)` deterministic.
|
||||
- `buildTopology(config): VirtualTopology` — throws `TOPOLOGY_INVALID` for invalid configs.
|
||||
- `function mulberry32(seed): () => number` — exported so callers can spawn additional independent streams.
|
||||
- `interface VirtualTopology` — `{ nodes, edges }`.
|
||||
- `interface VirtualEdge` — `{ from, to }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Mulberry32 is chosen for speed + zero state-leak between runs. Higher-entropy generators (xoshiro256**) would be overkill for scenario replay and would slow seed-replay tests.
|
||||
- `star` topology requires at least 2 nodes (hub + 1 spoke); the factory throws `TOPOLOGY_INVALID` for under-capacity configs.
|
||||
- `tiered` connects every node in tier _N_ with every node in tier _N+1_. Within-tier connections must be added via `custom` if needed.
|
||||
- Backward time-travel is intentionally blocked because the scenario runner relies on monotonic time to schedule events deterministically.
|
||||
@@ -0,0 +1,195 @@
|
||||
import { NETWORK_SIM_CODES, NetworkSimError } from "../error-codes.js";
|
||||
import type {
|
||||
NetworkTopologyConfig,
|
||||
SimulationConfig,
|
||||
VirtualNodeConfig,
|
||||
} from "../types/simulation.types.js";
|
||||
|
||||
/**
|
||||
* Mulberry32 PRNG. 32-bit state, fast, deterministic, good enough for
|
||||
* scenario-replay reproducibility.
|
||||
*/
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let state = (seed | 0) >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VirtualEdge {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
}
|
||||
|
||||
export interface VirtualTopology {
|
||||
readonly nodes: ReadonlyArray<VirtualNodeConfig>;
|
||||
readonly edges: ReadonlyArray<VirtualEdge>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic, time-controlled simulator. Owns the seeded RNG, the
|
||||
* simulated wall clock, and the topology factory; consumers wrap a
|
||||
* `NetworkSimulator` instance with `VirtualNode`s, `ScenarioRunner`,
|
||||
* `NetworkConditionInjector`, and `ChaosEngine`.
|
||||
*/
|
||||
export class NetworkSimulator {
|
||||
private readonly seed: number;
|
||||
private readonly timeFactor: number;
|
||||
private currentTimeMs: number;
|
||||
private readonly random: () => number;
|
||||
|
||||
public constructor(config: SimulationConfig) {
|
||||
if (config.timeFactor !== undefined && config.timeFactor <= 0) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.TIME_INVALID,
|
||||
"simulator.time-factor-invalid",
|
||||
{ context: { timeFactor: config.timeFactor } }
|
||||
);
|
||||
}
|
||||
this.seed = config.seed;
|
||||
this.timeFactor = config.timeFactor ?? 1;
|
||||
this.currentTimeMs = config.initialTimeMs ?? 0;
|
||||
this.random = mulberry32(this.seed);
|
||||
}
|
||||
|
||||
public getSeed(): number {
|
||||
return this.seed;
|
||||
}
|
||||
|
||||
public now(): number {
|
||||
return this.currentTimeMs;
|
||||
}
|
||||
|
||||
public timeFactorValue(): number {
|
||||
return this.timeFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance simulated time by `wallMs` real-world milliseconds — the
|
||||
* simulator multiplies by `timeFactor` so a `timeFactor: 100` clock
|
||||
* advances 100ms of simulated time per real ms.
|
||||
*/
|
||||
public advanceBy(wallMs: number): number {
|
||||
if (wallMs <= 0) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.TIME_INVALID,
|
||||
"simulator.advance-non-positive",
|
||||
{ context: { wallMs } }
|
||||
);
|
||||
}
|
||||
this.currentTimeMs += wallMs * this.timeFactor;
|
||||
return this.currentTimeMs;
|
||||
}
|
||||
|
||||
public advanceTo(absoluteSimulatedMs: number): number {
|
||||
if (absoluteSimulatedMs < this.currentTimeMs) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.TIME_INVALID,
|
||||
"simulator.advance-back-in-time",
|
||||
{
|
||||
context: {
|
||||
from: this.currentTimeMs,
|
||||
to: absoluteSimulatedMs,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
this.currentTimeMs = absoluteSimulatedMs;
|
||||
return this.currentTimeMs;
|
||||
}
|
||||
|
||||
public rand(): number {
|
||||
return this.random();
|
||||
}
|
||||
|
||||
/** Random integer in `[min, max)` driven by the seeded RNG. */
|
||||
public randInt(min: number, max: number): number {
|
||||
return Math.floor(this.random() * (max - min)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `VirtualTopology` for the configured topology kind.
|
||||
* `custom` requires `edges` to be present.
|
||||
*/
|
||||
public buildTopology(config: NetworkTopologyConfig): VirtualTopology {
|
||||
switch (config.kind) {
|
||||
case "star":
|
||||
return this.buildStar(config);
|
||||
case "mesh":
|
||||
return this.buildMesh(config);
|
||||
case "tiered":
|
||||
return this.buildTiered(config);
|
||||
case "custom":
|
||||
return this.buildCustom(config);
|
||||
}
|
||||
}
|
||||
|
||||
private buildStar(config: NetworkTopologyConfig): VirtualTopology {
|
||||
const nodes = config.nodes;
|
||||
if (nodes.length < 2) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.TOPOLOGY_INVALID,
|
||||
"topology.star-needs-2",
|
||||
{ context: { nodeCount: nodes.length } }
|
||||
);
|
||||
}
|
||||
const hub = nodes[0] as VirtualNodeConfig;
|
||||
const edges: VirtualEdge[] = [];
|
||||
for (let i = 1; i < nodes.length; i += 1) {
|
||||
const spoke = nodes[i] as VirtualNodeConfig;
|
||||
edges.push({ from: hub.id, to: spoke.id });
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
private buildMesh(config: NetworkTopologyConfig): VirtualTopology {
|
||||
const nodes = config.nodes;
|
||||
const edges: VirtualEdge[] = [];
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
for (let j = i + 1; j < nodes.length; j += 1) {
|
||||
const a = nodes[i] as VirtualNodeConfig;
|
||||
const b = nodes[j] as VirtualNodeConfig;
|
||||
edges.push({ from: a.id, to: b.id });
|
||||
}
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
private buildTiered(config: NetworkTopologyConfig): VirtualTopology {
|
||||
const nodes = config.nodes;
|
||||
const byTier = new Map<number, VirtualNodeConfig[]>();
|
||||
for (const node of nodes) {
|
||||
const bucket = byTier.get(node.tier) ?? [];
|
||||
bucket.push(node);
|
||||
byTier.set(node.tier, bucket);
|
||||
}
|
||||
const edges: VirtualEdge[] = [];
|
||||
const tiers = Array.from(byTier.keys()).sort((a, b) => a - b);
|
||||
for (let i = 0; i < tiers.length - 1; i += 1) {
|
||||
const upper = byTier.get(tiers[i] as number) as VirtualNodeConfig[];
|
||||
const lower = byTier.get(
|
||||
tiers[i + 1] as number
|
||||
) as VirtualNodeConfig[];
|
||||
for (const u of upper) {
|
||||
for (const l of lower) {
|
||||
edges.push({ from: u.id, to: l.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
private buildCustom(config: NetworkTopologyConfig): VirtualTopology {
|
||||
if (config.edges === undefined) {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.TOPOLOGY_INVALID,
|
||||
"topology.custom-missing-edges"
|
||||
);
|
||||
}
|
||||
return { nodes: config.nodes, edges: config.edges };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_SIM_CODES, type NetworkSimError } from "../error-codes.js";
|
||||
import { mulberry32, NetworkSimulator } from "./network-simulator.js";
|
||||
|
||||
describe("NetworkSimulator: determinism + time + topology factories", () => {
|
||||
it("seeded RNG produces identical streams", () => {
|
||||
const a = new NetworkSimulator({ seed: 42 });
|
||||
const b = new NetworkSimulator({ seed: 42 });
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
expect(a.rand()).toBe(b.rand());
|
||||
}
|
||||
});
|
||||
|
||||
it("getSeed and timeFactorValue return the configured values", () => {
|
||||
const sim = new NetworkSimulator({ seed: 7, timeFactor: 3 });
|
||||
expect(sim.getSeed()).toBe(7);
|
||||
expect(sim.timeFactorValue()).toBe(3);
|
||||
});
|
||||
|
||||
it("advanceBy multiplies by timeFactor", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1, timeFactor: 100 });
|
||||
expect(sim.advanceBy(5)).toBe(500);
|
||||
expect(sim.now()).toBe(500);
|
||||
});
|
||||
|
||||
it("advanceTo sets the simulated clock", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
expect(sim.advanceTo(1_000)).toBe(1_000);
|
||||
});
|
||||
|
||||
it("advanceBy rejects non-positive deltas", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
try {
|
||||
sim.advanceBy(0);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkSimError).code).toBe(
|
||||
NETWORK_SIM_CODES.TIME_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("advanceTo rejects backward jumps", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
sim.advanceTo(100);
|
||||
try {
|
||||
sim.advanceTo(50);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkSimError).code).toBe(
|
||||
NETWORK_SIM_CODES.TIME_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("constructor rejects non-positive timeFactor", () => {
|
||||
try {
|
||||
new NetworkSimulator({ seed: 1, timeFactor: 0 });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkSimError).code).toBe(
|
||||
NETWORK_SIM_CODES.TIME_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("randInt returns integers within bounds", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const v = sim.randInt(5, 10);
|
||||
expect(Number.isInteger(v)).toBe(true);
|
||||
expect(v).toBeGreaterThanOrEqual(5);
|
||||
expect(v).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it("buildTopology star creates hub-spoke edges", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const t = sim.buildTopology({
|
||||
kind: "star",
|
||||
nodes: [
|
||||
{ id: "h", tier: 0 },
|
||||
{ id: "a", tier: 1 },
|
||||
{ id: "b", tier: 1 },
|
||||
],
|
||||
});
|
||||
expect(t.edges).toEqual([
|
||||
{ from: "h", to: "a" },
|
||||
{ from: "h", to: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("buildTopology star rejects under-2 nodes", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
try {
|
||||
sim.buildTopology({
|
||||
kind: "star",
|
||||
nodes: [{ id: "h", tier: 0 }],
|
||||
});
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkSimError).code).toBe(
|
||||
NETWORK_SIM_CODES.TOPOLOGY_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("buildTopology mesh creates n*(n-1)/2 edges", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const t = sim.buildTopology({
|
||||
kind: "mesh",
|
||||
nodes: [
|
||||
{ id: "a", tier: 0 },
|
||||
{ id: "b", tier: 0 },
|
||||
{ id: "c", tier: 0 },
|
||||
],
|
||||
});
|
||||
expect(t.edges.length).toBe(3);
|
||||
});
|
||||
|
||||
it("buildTopology tiered connects between adjacent tiers only", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const t = sim.buildTopology({
|
||||
kind: "tiered",
|
||||
nodes: [
|
||||
{ id: "h1", tier: 0 },
|
||||
{ id: "h2", tier: 0 },
|
||||
{ id: "s1", tier: 1 },
|
||||
{ id: "l1", tier: 2 },
|
||||
],
|
||||
});
|
||||
// 2 tier-0 × 1 tier-1 + 1 tier-1 × 1 tier-2 = 3 edges
|
||||
expect(t.edges.length).toBe(3);
|
||||
});
|
||||
|
||||
it("buildTopology custom requires edges", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
try {
|
||||
sim.buildTopology({
|
||||
kind: "custom",
|
||||
nodes: [{ id: "a", tier: 0 }],
|
||||
});
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkSimError).code).toBe(
|
||||
NETWORK_SIM_CODES.TOPOLOGY_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("buildTopology custom returns the supplied edges verbatim", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const edges = [{ from: "a", to: "b" }];
|
||||
const t = sim.buildTopology({
|
||||
kind: "custom",
|
||||
nodes: [
|
||||
{ id: "a", tier: 0 },
|
||||
{ id: "b", tier: 0 },
|
||||
],
|
||||
edges,
|
||||
});
|
||||
expect(t.edges).toBe(edges);
|
||||
});
|
||||
|
||||
it("uses sensible defaults for timeFactor + initialTimeMs", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
expect(sim.timeFactorValue()).toBe(1);
|
||||
expect(sim.now()).toBe(0);
|
||||
});
|
||||
|
||||
it("mulberry32 is exported and deterministic", () => {
|
||||
const a = mulberry32(99);
|
||||
const b = mulberry32(99);
|
||||
expect(a()).toBe(b());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
# Condition types — `@suaveplan/network-plugin-simulation`
|
||||
|
||||
## Purpose
|
||||
|
||||
Discriminated unions describing how the `NetworkConditionInjector` deforms simulated traffic. `LatencyModel` covers constant / random / geographic latency curves; `LossPattern` covers random / burst / per-connection drop rates; `BandwidthLimit` covers fixed / variable / congestion-based throughput caps; `JitterConfig` adds amplitude + periodic latency variation; `DisruptionConfig` simulates temporary or permanent connection failures. `NetworkConditionConfig` bundles them so callers configure the entire conditions surface in one struct.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated `kind` field on each model so injectors exhaustively switch — adding a new model is a one-place change.
|
||||
- All time + bandwidth values use ms / bytes-per-sec so the injector does not perform unit conversions per-call.
|
||||
- Permanent vs temporary disruption distinguishable via the optional `permanent` flag.
|
||||
- All inner records are `Readonly` so configurations cannot mutate after construction.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
LatencyModel,
|
||||
LossPattern,
|
||||
NetworkConditionConfig,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const latency: LatencyModel = { kind: "constant", latencyMs: 50 };
|
||||
const loss: LossPattern = { kind: "random", probability: 0.01 };
|
||||
const conditions: NetworkConditionConfig = { latency, loss };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { NetworkConditionConfig } from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const conditions: NetworkConditionConfig = {
|
||||
latency: {
|
||||
kind: "geographic",
|
||||
perRegionMs: { "us-east": 30, "eu-west": 80, "ap-south": 220 },
|
||||
},
|
||||
loss: {
|
||||
kind: "burst",
|
||||
burstProbability: 0.005,
|
||||
burstLength: 5,
|
||||
},
|
||||
bandwidth: {
|
||||
kind: "congestion",
|
||||
baseBytesPerSec: 5_000_000,
|
||||
congestionFactor: 0.6,
|
||||
},
|
||||
jitter: { amplitudeMs: 20, periodMs: 5_000 },
|
||||
disruption: { probability: 0.001, durationMs: 1_000 },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type LatencyModel` — `constant | random | geographic`.
|
||||
- `type LossPattern` — `random | burst | connection-based`.
|
||||
- `type BandwidthLimit` — `fixed | variable | congestion`.
|
||||
- `interface JitterConfig` — `{ amplitudeMs, periodMs }`.
|
||||
- `interface DisruptionConfig` — `{ probability, durationMs, permanent? }`.
|
||||
- `interface NetworkConditionConfig` — `{ latency?, loss?, bandwidth?, jitter?, disruption? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `connection-based` loss keys connections by `"<from>:<to>"` strings. Callers must normalise the order if they want bidirectional pairing.
|
||||
- `congestion`'s `congestionFactor` is the fractional multiplier applied to `baseBytesPerSec` while congestion is active; the injector decides when to apply it.
|
||||
- `disruption` does not embed cooldown semantics — when `permanent: false`, the injector simply restores the connection after `durationMs`. Patterns like "X% disruption per minute" should compose multiple disruption configs across scenario events.
|
||||
- `jitter.periodMs` is the wave period — amplitude is added on a sine curve so latency oscillates smoothly rather than stepping abruptly.
|
||||
@@ -0,0 +1,57 @@
|
||||
export type LatencyModel =
|
||||
| { readonly kind: "constant"; readonly latencyMs: number }
|
||||
| {
|
||||
readonly kind: "random";
|
||||
readonly minMs: number;
|
||||
readonly maxMs: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "geographic";
|
||||
readonly perRegionMs: Readonly<Record<string, number>>;
|
||||
};
|
||||
|
||||
export type LossPattern =
|
||||
| { readonly kind: "random"; readonly probability: number }
|
||||
| {
|
||||
readonly kind: "burst";
|
||||
readonly burstProbability: number;
|
||||
readonly burstLength: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "connection-based";
|
||||
readonly probabilityPerEdge: Readonly<Record<string, number>>;
|
||||
};
|
||||
|
||||
export type BandwidthLimit =
|
||||
| { readonly kind: "fixed"; readonly bytesPerSec: number }
|
||||
| {
|
||||
readonly kind: "variable";
|
||||
readonly minBytesPerSec: number;
|
||||
readonly maxBytesPerSec: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "congestion";
|
||||
readonly baseBytesPerSec: number;
|
||||
readonly congestionFactor: number;
|
||||
};
|
||||
|
||||
export interface JitterConfig {
|
||||
readonly amplitudeMs: number;
|
||||
readonly periodMs: number;
|
||||
}
|
||||
|
||||
export interface DisruptionConfig {
|
||||
/** Probability per evaluate call. */
|
||||
readonly probability: number;
|
||||
readonly durationMs: number;
|
||||
/** True when the disruption persists past `durationMs`. */
|
||||
readonly permanent?: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkConditionConfig {
|
||||
readonly latency?: LatencyModel;
|
||||
readonly loss?: LossPattern;
|
||||
readonly bandwidth?: BandwidthLimit;
|
||||
readonly jitter?: JitterConfig;
|
||||
readonly disruption?: DisruptionConfig;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Scenario types — `@suaveplan/network-plugin-simulation`
|
||||
|
||||
## Purpose
|
||||
|
||||
Declarative shapes the `ScenarioRunner` consumes. `SimulationScenario` describes setup + ordered events + success criteria; `ScenarioEvent` is the unit of mid-run mutation; `SuccessCriterion` is a callable predicate the runner evaluates at scenario end (and optionally at every checkpoint); `ScenarioResult` is the report the runner returns.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed `ScenarioEventType` union covers join / leave / fail / partition / heal / condition-change / checkpoint.
|
||||
- `SuccessCriterion.evaluate` is a function so callers express arbitrary assertions without inventing a DSL.
|
||||
- `ScenarioCheckpoint` records the per-criterion result snapshot at a moment in simulated time, useful for debugging long runs.
|
||||
- `ScenarioResult.metrics` is `Record<string, number>` so runners report event counts / latency averages without a richer schema.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ScenarioEvent,
|
||||
SimulationScenario,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const scenario: SimulationScenario = {
|
||||
name: "ping-storm",
|
||||
seed: 1,
|
||||
initialTopology: { kind: "mesh", nodes: [{ id: "n1", tier: 0 }] },
|
||||
nodeCount: 1,
|
||||
events: [
|
||||
{ timeMs: 100, type: "fail", targetPeerIds: ["n1"] },
|
||||
],
|
||||
successCriteria: [],
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ScenarioCheckpoint,
|
||||
ScenarioResult,
|
||||
SuccessCriterion,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const allHealthy: SuccessCriterion = {
|
||||
id: "all-healthy",
|
||||
description: "every peer remains reachable",
|
||||
evaluate: (ctx) => ctx.simulatedTimeMs > 0,
|
||||
evaluateAtCheckpoints: true,
|
||||
};
|
||||
|
||||
declare const result: ScenarioResult;
|
||||
result.checkpoints.forEach((cp) => console.log(cp.atMs, cp.criteriaResults));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type ScenarioEventType` — closed union of seven event kinds.
|
||||
- `interface ScenarioEvent` — `{ timeMs, type, targetPeerIds?, condition?, label? }`.
|
||||
- `interface SuccessCriterion` — `{ id, description, evaluate(ctx), evaluateAtCheckpoints? }`.
|
||||
- `interface SimulationScenario` — `{ name, seed, initialTopology, nodeCount, events, networkConditions?, successCriteria }`.
|
||||
- `interface ScenarioCheckpoint` — `{ atMs, label?, criteriaResults }`.
|
||||
- `interface ScenarioResult` — `{ scenario, succeeded, checkpoints, criteriaResults, metrics, executedEvents }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `ScenarioEvent.targetPeerIds` is optional because some event types (e.g. `condition-change`) apply globally, while others (e.g. `fail`) target a specific peer set.
|
||||
- `SuccessCriterion.evaluate` is sync — the runner does not await asynchronous predicates because the simulator already lives in simulated time and async-await would re-introduce wall-clock dependence.
|
||||
- `ScenarioCheckpoint.criteriaResults` only includes criteria that opted in via `evaluateAtCheckpoints: true`. Final-run results live in `ScenarioResult.criteriaResults`.
|
||||
- `ScenarioResult.executedEvents` echoes the event list the runner actually fired (useful when scenarios are programmatically generated and the caller wants to confirm which events ran).
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { NetworkConditionConfig } from "./condition.types.js";
|
||||
import type { NetworkTopologyConfig } from "./simulation.types.js";
|
||||
|
||||
export type ScenarioEventType =
|
||||
| "join"
|
||||
| "leave"
|
||||
| "fail"
|
||||
| "partition"
|
||||
| "heal"
|
||||
| "condition-change"
|
||||
| "checkpoint";
|
||||
|
||||
export interface ScenarioEvent {
|
||||
readonly timeMs: number;
|
||||
readonly type: ScenarioEventType;
|
||||
readonly targetPeerIds?: ReadonlyArray<string>;
|
||||
readonly condition?: NetworkConditionConfig;
|
||||
readonly label?: string;
|
||||
}
|
||||
|
||||
export interface SuccessCriterion {
|
||||
readonly id: string;
|
||||
readonly description: string;
|
||||
/**
|
||||
* Returns `true` when the criterion holds at evaluation time. The
|
||||
* scenario runner calls every criterion at scenario end and (when
|
||||
* the criterion opts in) at every checkpoint.
|
||||
*/
|
||||
evaluate(context: { simulatedTimeMs: number }): boolean;
|
||||
readonly evaluateAtCheckpoints?: boolean;
|
||||
}
|
||||
|
||||
export interface SimulationScenario {
|
||||
readonly name: string;
|
||||
readonly seed: number;
|
||||
readonly initialTopology: NetworkTopologyConfig;
|
||||
readonly nodeCount: number;
|
||||
readonly events: ReadonlyArray<ScenarioEvent>;
|
||||
readonly networkConditions?: NetworkConditionConfig;
|
||||
readonly successCriteria: ReadonlyArray<SuccessCriterion>;
|
||||
}
|
||||
|
||||
export interface ScenarioCheckpoint {
|
||||
readonly atMs: number;
|
||||
readonly label?: string;
|
||||
readonly criteriaResults: Readonly<Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export interface ScenarioResult {
|
||||
readonly scenario: string;
|
||||
readonly succeeded: boolean;
|
||||
readonly checkpoints: ReadonlyArray<ScenarioCheckpoint>;
|
||||
readonly criteriaResults: Readonly<Record<string, boolean>>;
|
||||
readonly metrics: Readonly<Record<string, number>>;
|
||||
readonly executedEvents: ReadonlyArray<ScenarioEvent>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Simulation types — `@suaveplan/network-plugin-simulation`
|
||||
|
||||
## Purpose
|
||||
|
||||
Wire shapes for the `NetworkSimulator` constructor and topology factories. `SimulationConfig` carries the seed + time-factor + initial-time tuple every other module reads from. `VirtualNodeConfig` describes the per-peer hints topology factories consume. `NetworkTopologyConfig` selects between built-in topologies or supplies custom edges.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed `TopologyKind` union (`star` / `mesh` / `tiered` / `custom`) so factories exhaustively switch.
|
||||
- `seed` is required so simulations cannot accidentally diverge between runs.
|
||||
- Optional `transports` list per virtual node so condition injectors know which connection-types exist.
|
||||
- `timeFactor` defaults to `1` (real-time); test runs typically pick `100` or higher to compress long scenarios.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SimulationConfig,
|
||||
VirtualNodeConfig,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const config: SimulationConfig = { seed: 42, timeFactor: 100 };
|
||||
const nodes: VirtualNodeConfig[] = [
|
||||
{ id: "n1", tier: 0 },
|
||||
{ id: "n2", tier: 1, transports: ["websocket"] },
|
||||
];
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
NetworkTopologyConfig,
|
||||
SimulationConfig,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const config: SimulationConfig = {
|
||||
seed: 12345,
|
||||
timeFactor: 1000,
|
||||
initialTimeMs: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
const topology: NetworkTopologyConfig = {
|
||||
kind: "custom",
|
||||
nodes: Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `n${i}`,
|
||||
tier: i === 0 ? 0 : 1,
|
||||
})),
|
||||
edges: Array.from({ length: 9 }, (_, i) => ({
|
||||
from: "n0",
|
||||
to: `n${i + 1}`,
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SimulationConfig` — `{ seed, timeFactor?, initialTimeMs? }`.
|
||||
- `interface VirtualNodeConfig` — `{ id, tier, transports? }`.
|
||||
- `type TopologyKind` — `"star" | "mesh" | "tiered" | "custom"`.
|
||||
- `interface NetworkTopologyConfig` — `{ kind, nodes, edges? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `seed` is `number` rather than `bigint` so consumers serialise to JSON without conversion. The simulator uses a 32-bit Mulberry32 PRNG so 53-bit numbers are downcast safely.
|
||||
- `edges` on `NetworkTopologyConfig` is optional for built-in topologies because the factories derive the edge set from `nodes`. For `custom`, the factory rejects when `edges` is missing.
|
||||
- `VirtualNodeConfig.transports` lists transport identifiers, not concrete instances — keeps the type independent of the transport-package surface.
|
||||
- `timeFactor` is intentionally a multiplier (not a target FPS) so callers reason about it as "how much faster than real time".
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Configuration for `NetworkSimulator`. The seed feeds a
|
||||
* deterministic RNG so every run with identical config produces
|
||||
* byte-identical output.
|
||||
*/
|
||||
export interface SimulationConfig {
|
||||
readonly seed: number;
|
||||
/** Multiplier applied to wall-clock advances. Defaults to 1. */
|
||||
readonly timeFactor?: number;
|
||||
/** Initial wall-clock time the simulator reports. Defaults to 0. */
|
||||
readonly initialTimeMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-virtual-node capability hints — consumed by topology factories
|
||||
* to decide how many tiers / which transports to stub.
|
||||
*/
|
||||
export interface VirtualNodeConfig {
|
||||
readonly id: string;
|
||||
readonly tier: number;
|
||||
readonly transports?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export type TopologyKind = "star" | "mesh" | "tiered" | "custom";
|
||||
|
||||
export interface NetworkTopologyConfig {
|
||||
readonly kind: TopologyKind;
|
||||
readonly nodes: ReadonlyArray<VirtualNodeConfig>;
|
||||
/** For `custom`: explicit edges; for built-ins: auto-derived. */
|
||||
readonly edges?: ReadonlyArray<{ from: string; to: string }>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# `VirtualNode`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Simulated peer that wraps the same `NetworkNode` + plugin code real peers run, plus a status flag (`running` / `crashed` / `frozen` / `disconnected`) the scenario runner and chaos engine toggle to inject failures. Records every tick / send / receive / fail / recover event with the simulator's current simulated time, so test assertions can replay the timeline deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- Status state-machine with explicit `crash` / `freeze` / `disconnect` / `recover` transitions; idempotent (re-entering a state does not double-record).
|
||||
- `tick` / `recordSend` / `recordReceive` push events into a per-node history buffer.
|
||||
- `tick` throws `NODE_NOT_FOUND` when called against a non-running node so callers cannot observe ticks they did not actually run.
|
||||
- Read-only `events()` snapshot returns a clone so callers cannot mutate the underlying buffer.
|
||||
- `clearHistory()` for long scenarios that need to free memory between checkpoints.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkSimulator,
|
||||
VirtualNode,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const node = new VirtualNode({ id: "n1", tier: 0 }, sim);
|
||||
node.tick();
|
||||
node.crash();
|
||||
node.events().length; // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkSimulator,
|
||||
VirtualNode,
|
||||
} from "@suaveplan/network-plugin-simulation";
|
||||
|
||||
const sim = new NetworkSimulator({ seed: 7, timeFactor: 1_000 });
|
||||
const node = new VirtualNode(
|
||||
{ id: "n2", tier: 1, transports: ["websocket", "webrtc"] },
|
||||
sim
|
||||
);
|
||||
sim.advanceBy(1);
|
||||
node.recordSend({ kind: "ping" });
|
||||
sim.advanceBy(1);
|
||||
node.recordReceive({ kind: "pong" });
|
||||
|
||||
node.crash();
|
||||
sim.advanceBy(5);
|
||||
node.recover();
|
||||
node.tick();
|
||||
console.log(node.events().map((e) => `${e.atMs}:${e.type}`));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class VirtualNode(config, simulator)`
|
||||
- `getStatus(): VirtualNodeStatus`.
|
||||
- `crash(): void` / `freeze(): void` / `disconnect(): void` / `recover(): void` — idempotent transitions.
|
||||
- `tick(payload?): VirtualNodeEvent` — throws `NODE_NOT_FOUND` when not running.
|
||||
- `recordSend(payload): void` / `recordReceive(payload): void`.
|
||||
- `events(): ReadonlyArray<VirtualNodeEvent>` — non-destructive snapshot.
|
||||
- `clearHistory(): void`.
|
||||
- `type VirtualNodeStatus` — `"running" | "crashed" | "frozen" | "disconnected"`.
|
||||
- `interface VirtualNodeEvent` — `{ atMs, type, payload? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class is intentionally thin — it only tracks status + history. Wiring to a real `NetworkNode` instance happens in integration tests / predefined scenarios so the core simulator stays free of plugin-specific logic.
|
||||
- `crash` / `freeze` / `disconnect` all collapse to a single "fail" event in the history buffer; consumers wanting to distinguish should also read `getStatus()` after the failure.
|
||||
- `tick` rejects when not running because typical scenarios advance the simulator clock independently of node ticks; ticking a crashed node would produce a misleading "still alive" event in the history.
|
||||
- `recover` is idempotent on `running` so consumers can safely call it from event handlers without checking current status.
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_SIM_CODES, type NetworkSimError } from "../error-codes.js";
|
||||
import { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import { VirtualNode } from "./virtual-node.js";
|
||||
|
||||
const setup = () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const node = new VirtualNode(
|
||||
{ id: "n1", tier: 0, transports: ["websocket"] },
|
||||
sim
|
||||
);
|
||||
return { sim, node };
|
||||
};
|
||||
|
||||
describe("VirtualNode: status state-machine + history", () => {
|
||||
it("starts running with empty history", () => {
|
||||
const { node } = setup();
|
||||
expect(node.getStatus()).toBe("running");
|
||||
expect(node.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it("crash + freeze + disconnect transitions are idempotent", () => {
|
||||
const { node } = setup();
|
||||
node.crash();
|
||||
node.crash();
|
||||
expect(node.getStatus()).toBe("crashed");
|
||||
expect(node.events().length).toBe(1);
|
||||
});
|
||||
|
||||
it("recover restores running and records a recover event", () => {
|
||||
const { node } = setup();
|
||||
node.crash();
|
||||
node.recover();
|
||||
expect(node.getStatus()).toBe("running");
|
||||
const events = node.events();
|
||||
expect(events[events.length - 1]?.type).toBe("recover");
|
||||
});
|
||||
|
||||
it("recover is no-op when already running", () => {
|
||||
const { node } = setup();
|
||||
node.recover();
|
||||
expect(node.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it("tick records an event with simulator time", () => {
|
||||
const { sim, node } = setup();
|
||||
sim.advanceBy(5);
|
||||
const event = node.tick();
|
||||
expect(event.atMs).toBe(5);
|
||||
expect(event.type).toBe("tick");
|
||||
});
|
||||
|
||||
it("tick with payload records the payload", () => {
|
||||
const { node } = setup();
|
||||
const event = node.tick({ kind: "ping" });
|
||||
expect(event.payload).toEqual({ kind: "ping" });
|
||||
});
|
||||
|
||||
it("tick on a non-running node throws NODE_NOT_FOUND", () => {
|
||||
const { node } = setup();
|
||||
node.crash();
|
||||
try {
|
||||
node.tick();
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkSimError).code).toBe(
|
||||
NETWORK_SIM_CODES.NODE_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("recordSend / recordReceive append to history", () => {
|
||||
const { node } = setup();
|
||||
node.recordSend({ kind: "ping" });
|
||||
node.recordReceive({ kind: "pong" });
|
||||
const types = node.events().map((e) => e.type);
|
||||
expect(types).toEqual(["send", "receive"]);
|
||||
});
|
||||
|
||||
it("freeze + disconnect cover the other failure transitions", () => {
|
||||
const { node: a } = setup();
|
||||
a.freeze();
|
||||
expect(a.getStatus()).toBe("frozen");
|
||||
const { node: b } = setup();
|
||||
b.disconnect();
|
||||
expect(b.getStatus()).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("clearHistory empties the buffer without affecting status", () => {
|
||||
const { node } = setup();
|
||||
node.tick();
|
||||
node.crash();
|
||||
node.clearHistory();
|
||||
expect(node.events()).toEqual([]);
|
||||
expect(node.getStatus()).toBe("crashed");
|
||||
});
|
||||
|
||||
it("events returns a defensive copy", () => {
|
||||
const { node } = setup();
|
||||
node.tick();
|
||||
const snap = node.events();
|
||||
(snap as unknown as Array<unknown>).push(null);
|
||||
expect(node.events().length).toBe(1);
|
||||
});
|
||||
|
||||
it("default transports list is empty when none supplied", () => {
|
||||
const sim = new NetworkSimulator({ seed: 1 });
|
||||
const node = new VirtualNode({ id: "x", tier: 0 }, sim);
|
||||
expect(node.transports).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NETWORK_SIM_CODES, NetworkSimError } from "../error-codes.js";
|
||||
import type { NetworkSimulator } from "../simulator/network-simulator.js";
|
||||
import type { VirtualNodeConfig } from "../types/simulation.types.js";
|
||||
|
||||
export type VirtualNodeStatus =
|
||||
| "running"
|
||||
| "crashed"
|
||||
| "frozen"
|
||||
| "disconnected";
|
||||
|
||||
export interface VirtualNodeEvent {
|
||||
readonly atMs: number;
|
||||
readonly type: "tick" | "send" | "receive" | "fail" | "recover";
|
||||
readonly payload?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulated peer wrapping a `NetworkNode` (left to integration tests
|
||||
* to wire) plus a status flag the simulator toggles for failure
|
||||
* scenarios. Tracks per-node event history for assertion in the
|
||||
* `ScenarioRunner` and `ChaosEngine`.
|
||||
*/
|
||||
export class VirtualNode {
|
||||
public readonly id: string;
|
||||
public readonly tier: number;
|
||||
public readonly transports: ReadonlyArray<string>;
|
||||
private status: VirtualNodeStatus = "running";
|
||||
private readonly history: VirtualNodeEvent[] = [];
|
||||
private readonly simulator: NetworkSimulator;
|
||||
|
||||
public constructor(config: VirtualNodeConfig, simulator: NetworkSimulator) {
|
||||
this.id = config.id;
|
||||
this.tier = config.tier;
|
||||
this.transports = config.transports ?? [];
|
||||
this.simulator = simulator;
|
||||
}
|
||||
|
||||
public getStatus(): VirtualNodeStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
public crash(): void {
|
||||
this.transition("crashed", "fail");
|
||||
}
|
||||
|
||||
public freeze(): void {
|
||||
this.transition("frozen", "fail");
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.transition("disconnected", "fail");
|
||||
}
|
||||
|
||||
public recover(): void {
|
||||
if (this.status === "running") return;
|
||||
this.status = "running";
|
||||
this.history.push({
|
||||
atMs: this.simulator.now(),
|
||||
type: "recover",
|
||||
});
|
||||
}
|
||||
|
||||
public tick(payload?: unknown): VirtualNodeEvent {
|
||||
if (this.status !== "running") {
|
||||
throw new NetworkSimError(
|
||||
NETWORK_SIM_CODES.NODE_NOT_FOUND,
|
||||
"node.tick-while-not-running",
|
||||
{ context: { nodeId: this.id, status: this.status } }
|
||||
);
|
||||
}
|
||||
const event: VirtualNodeEvent =
|
||||
payload === undefined
|
||||
? { atMs: this.simulator.now(), type: "tick" }
|
||||
: { atMs: this.simulator.now(), type: "tick", payload };
|
||||
this.history.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
public recordSend(payload: unknown): void {
|
||||
this.history.push({
|
||||
atMs: this.simulator.now(),
|
||||
type: "send",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
public recordReceive(payload: unknown): void {
|
||||
this.history.push({
|
||||
atMs: this.simulator.now(),
|
||||
type: "receive",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
public events(): ReadonlyArray<VirtualNodeEvent> {
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
public clearHistory(): void {
|
||||
this.history.length = 0;
|
||||
}
|
||||
|
||||
private transition(
|
||||
next: VirtualNodeStatus,
|
||||
type: VirtualNodeEvent["type"]
|
||||
): void {
|
||||
if (this.status === next) return;
|
||||
this.status = next;
|
||||
this.history.push({
|
||||
atMs: this.simulator.now(),
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-plugin-simulation dep-graph", () => {
|
||||
it("loads index.js and exports every public class", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod.NetworkSimulator).toBeDefined();
|
||||
expect(mod.VirtualNode).toBeDefined();
|
||||
expect(mod.ScenarioRunner).toBeDefined();
|
||||
expect(mod.NetworkConditionInjector).toBeDefined();
|
||||
expect(mod.ChaosEngine).toBeDefined();
|
||||
expect(mod.SimulationPlugin).toBeDefined();
|
||||
expect(mod.mulberry32).toBeDefined();
|
||||
expect(mod.validateScenario).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
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-simulation",
|
||||
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