Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Engine } from './src/sim/engine';
import { makeNode } from './src/sim/presets';
import type { Topology } from './src/sim/types';

const HOSTILE: [string, unknown][] = [
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY],
['-Infinity', Number.NEGATIVE_INFINITY],
['1e9', 1e9],
['-4', -4],
];

const FIELDS: [string, string][] = [
['partitions', 'streambroker'],
['partitions', 'pubsub'],
['renditions', 'transcoder'],
['cpuMsCap', 'transcoder'],
['halfOpenProbes', 'breaker'],
['errorThreshold', 'breaker'],
['windowMs', 'breaker'],
['openMs', 'breaker'],
['batchSize', 'cron'],
['intervalMs', 'cron'],
['maxConcurrency', 'lambda'],
['coldStartMs', 'lambda'],
['keepWarmMs', 'lambda'],
['bulkheadMax', 'bulkhead'],
['traversalDepth', 'graphdb'],
['shardCapacity', 'shard'],
['hotKeyFraction', 'shard'],
['replicationLagMs', 'replica'],
['readFraction', 'replica'],
['indexSizeK', 'vectordb'],
['recallTarget', 'vectordb'],
['indexMs', 'searchindex'],
['indexLagMs', 'searchindex'],
['rangeQueryFraction', 'timeseriesdb'],
['rangeQueryMs', 'timeseriesdb'],
['connectionMs', 'websocket'],
['authFailRate', 'apigateway'],
['outlierAfter', 'sidecar'],
['flushDelayMs', 'writebehind'],
['edgeShare', 'edgecompute'],
['lowPriorityShare', 'loadshedder'],
['priorityReserve', 'loadshedder'],
['prefixRps', 'apigateway'],
['lockMs', 'writebehind'],
];

function topo(kind: string, field: string, value: unknown): Topology {
const client = { ...makeNode('client', 0, 0), id: 'client' };
client.config = { ...client.config, rps: 60 };
const target = { ...makeNode(kind as never, 200, 0), id: 'target' };
target.config = { ...target.config, [field]: value } as typeof target.config;
const sink = { ...makeNode('service', 400, 0), id: 'sink' };
const sink2 = { ...makeNode('service', 400, 120), id: 'sink2' };
return {
nodes: [client, target, sink, sink2],
edges: [
{ id: 'e1', from: 'client', to: 'target', weight: 1 },
{ id: 'e2', from: 'target', to: 'sink', weight: 1 },
{ id: 'e3', from: 'target', to: 'sink2', weight: 1 },
],
};
}

function hasNaN(v: unknown, depth = 0): boolean {
if (depth > 4) return false;
if (typeof v === 'number') return Number.isNaN(v);
if (Array.isArray(v)) return v.some((x) => hasNaN(x, depth + 1));
if (v && typeof v === 'object')
return Object.values(v).some((x) => hasNaN(x, depth + 1));
return false;
}

const [wantField, wantLabel] = [process.argv[2], process.argv[3]];
for (const [field, kind] of FIELDS) {
for (const [label, value] of HOSTILE) {
if (field !== wantField || label !== wantLabel) continue;
const t0 = performance.now();
let verdict = 'ok';
try {
const engine = new Engine(topo(kind, field, value), 7);
let nan = false;
for (let i = 0; i < 120; i++) {
engine.advance(1000 / 60);
if (i % 20 === 0 && hasNaN(engine.snapshot())) nan = true;
}
if (hasNaN(engine.snapshot())) nan = true;
verdict = nan ? 'NaN-IN-SNAPSHOT' : 'ok';
} catch (e) {
verdict = `THROW ${(e as Error).constructor.name}: ${(e as Error).message.slice(0, 60)}`;
}
const ms = Math.round(performance.now() - t0);
{
console.log(`${kind}.${field} = ${label.padEnd(10)} -> ${verdict} (${ms}ms)`);
}
}
}
console.log('--- probe done');
90 changes: 90 additions & 0 deletions src/sim/behaviour-data.bounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { Engine } from './engine';
import { makeNode } from './presets';
import type { NodeStats, Topology } from './types';

/*
* `shardCount`, `replicaCount` and `shardCapacity` size the structures the
* data components keep on `state.ext`, and they reach the engine from places
* that are not the inspector: a shared link, a `.breakscale` file and a
* restored session all carry them through, and `isTopology` checks the nine
* core config numbers and none of these three.
*
* So what the engine does with a number the sliders could never produce is a
* property of the engine rather than of the form that fed it.
*/

function topology(kind: 'shard' | 'replica', patch: Record<string, unknown>): Topology {
const client = { ...makeNode('client', 0, 0), id: 'client' };
client.config = { ...client.config, rps: 60 };
const target = { ...makeNode(kind, 200, 0), id: 'target' };
target.config = { ...target.config, ...patch } as typeof target.config;
return {
nodes: [client, target],
edges: [{ id: 'client->target', from: 'client', to: 'target', weight: 1 }],
};
}

function statsFor(
kind: 'shard' | 'replica',
patch: Record<string, unknown>,
): NodeStats {
const engine = new Engine(topology(kind, patch), 7);
for (let i = 0; i < 60; i += 1) engine.advance(1000 / 60);
return engine.snapshot().nodes['target'] as NodeStats;
}

describe('the fleet counts the data components will act on', () => {
it('runs a shard whose count is not a number', () => {
// `Math.floor(NaN)` is NaN and NaN fails `n < min`, so the count used to
// pass through to `new Array(count)`, which throws for it.
expect(() => statsFor('shard', { shardCount: Number.NaN })).not.toThrow();
});

it('runs a shard whose count is larger than the editor can set', () => {
// One queue array, one Int32Array and one Float64Array are built per
// shard, so an unbounded count does not come back at all.
const start = performance.now();
const stats = statsFor('shard', { shardCount: 1e9 });
expect(performance.now() - start).toBeLessThan(2000);
expect(stats.shardUtilization?.length).toBeLessThanOrEqual(64);
});

it('runs a shard whose count is infinite', () => {
const stats = statsFor('shard', { shardCount: Number.POSITIVE_INFINITY });
expect(stats.shardUtilization?.length).toBeLessThanOrEqual(64);
});

it('reports a real utilization for a shard capacity that is not a number', () => {
// The capacity is the divisor of the utilization the panel prints, so a
// count that is not a number reaches the reader as "NaN%".
const stats = statsFor('shard', { shardCapacity: Number.NaN });
expect(Number.isNaN(stats.utilization)).toBe(false);
for (const u of stats.shardUtilization ?? []) expect(Number.isNaN(u)).toBe(false);
});

it('runs a replica set whose count is not a number', () => {
// `instanceScratch` sets `array.length` from the count, which throws.
expect(() => statsFor('replica', { replicaCount: Number.NaN })).not.toThrow();
});

it('runs a replica set whose count is infinite', () => {
expect(() =>
statsFor('replica', { replicaCount: Number.POSITIVE_INFINITY }),
).not.toThrow();
});

it('runs a replica set whose count is larger than the editor can set', () => {
const start = performance.now();
const stats = statsFor('replica', { replicaCount: 1e9 });
expect(performance.now() - start).toBeLessThan(2000);
expect(stats.perInstance?.length ?? 0).toBeLessThanOrEqual(65);
});

it('leaves a count the editor can set exactly where it was', () => {
// The ceilings are the inspector's own maxima, so nothing a reader can
// build moves: eight shards are still eight shards.
const stats = statsFor('shard', { shardCount: 8 });
expect(stats.shardUtilization?.length).toBe(8);
});
});
40 changes: 31 additions & 9 deletions src/sim/behaviour-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,28 @@ import type { AdmitAction, ComponentBehaviour } from './behaviour';
/** Keyspace the engine draws request keys from; mirrored here for sizing. */
const KEYSPACE = 64;

function clampInt(v: number, min: number): number {
/* Fleet ceilings, each the maximum the inspector already offers for the
* field, so nothing a reader can build is affected by them. They exist for
* the designs a reader cannot build: `isTopology` checks the nine core
* config numbers and none of these, so a shared link, a `.breakscale` file
* and a restored session all carry whatever they say straight to the
* structures below, which are sized from it. */
const MAX_SHARDS = 64;
const MAX_REPLICAS = 64;
const MAX_SHARD_CAPACITY = 512;

/**
* A count from config: floored, held at `min`, and capped at `max`.
*
* NaN fails every comparison, so `n < min` was false for it and a value that
* is not a number used to pass through and size an array. Nothing here can
* absorb that: `new Array(NaN)` throws outright, and a count in the billions
* allocates until the tab stops rather than merely running slowly.
*/
function clampInt(v: number, min: number, max = Number.MAX_SAFE_INTEGER): number {
if (!Number.isFinite(v)) return min;
const n = Math.floor(v);
return n < min ? min : n;
return n < min ? min : n > max ? max : n;
}

function clamp01(v: number): number {
Expand Down Expand Up @@ -80,7 +99,10 @@ function replicaExt(state: NodeStateLike): ReplicaExt {

/** Total read slots: every replica serves reads in parallel. */
function readCapacity(state: NodeStateLike): number {
return clampInt(state.config.capacity, 1) * clampInt(state.config.replicaCount, 1);
return (
clampInt(state.config.capacity, 1) *
clampInt(state.config.replicaCount, 1, MAX_REPLICAS)
);
}

/** Write slots: the primary alone, which is why writes do not scale. */
Expand Down Expand Up @@ -156,7 +178,7 @@ const replica: ComponentBehaviour = {
*/
reportInstances(ctx: BehaviourCtx, state: NodeStateLike): void {
const ext = replicaExt(state);
const replicas = clampInt(state.config.replicaCount, 1);
const replicas = clampInt(state.config.replicaCount, 1, MAX_REPLICAS);
const out = instanceScratch(replicas + 1);

const writeCap = writeCapacity(state);
Expand Down Expand Up @@ -339,7 +361,7 @@ function makeShardExt(count: number): ShardExt {
* the shard their key now maps to, rather than being silently dropped.
*/
function ensureSized(state: NodeStateLike, ext: ShardExt): ShardExt {
const want = clampInt(state.config.shardCount, 1);
const want = clampInt(state.config.shardCount, 1, MAX_SHARDS);
if (ext.sized === want) return ext;

const orphans: ReqLike[] = [];
Expand Down Expand Up @@ -408,14 +430,14 @@ const shard: ComponentBehaviour = {
},

initState(state: NodeStateLike): ShardExt {
return makeShardExt(clampInt(state.config.shardCount, 1));
return makeShardExt(clampInt(state.config.shardCount, 1, MAX_SHARDS));
},

onAdmit(ctx: BehaviourCtx, state: NodeStateLike, req: ReqLike): AdmitAction {
const ext = ensureSized(state, shardExt(state));
const count = ext.sized;
const idx = shardIndexFor(ctx, state, req, count);
const capacity = clampInt(state.config.shardCapacity, 1);
const capacity = clampInt(state.config.shardCapacity, 1, MAX_SHARD_CAPACITY);

if (ext.busy[idx] < capacity) {
startShardService(ctx, state, ext, idx, req);
Expand All @@ -438,7 +460,7 @@ const shard: ComponentBehaviour = {
const dt = ctx.now - ext.lastIntegrateMs;
ext.lastIntegrateMs = ctx.now;
if (dt <= 0) return;
const capacity = clampInt(state.config.shardCapacity, 1);
const capacity = clampInt(state.config.shardCapacity, 1, MAX_SHARD_CAPACITY);
const alpha = 1 - Math.exp(-dt / 500);
let busy = 0;
for (let i = 0; i < ext.sized; i++) {
Expand Down Expand Up @@ -504,7 +526,7 @@ function onShardDrained(ctx: BehaviourCtx, state: NodeStateLike, req: ReqLike):
if (idx === undefined || idx >= ext.sized) return;
if (ext.busy[idx] > 0) ext.busy[idx]--;

const capacity = clampInt(state.config.shardCapacity, 1);
const capacity = clampInt(state.config.shardCapacity, 1, MAX_SHARD_CAPACITY);
const q = ext.queues[idx];
while (ext.busy[idx] < capacity && ext.heads[idx] < q.length) {
const next = q[ext.heads[idx]];
Expand Down
Loading