From 00177f528a13f067b2e19244757635df736abc10 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:42:47 +0900 Subject: [PATCH] fix(sim): bound the fleet counts the data components read --- probe.ts | 100 ++++++++++++++++++++++++++ src/sim/behaviour-data.bounds.test.ts | 90 +++++++++++++++++++++++ src/sim/behaviour-data.ts | 40 ++++++++--- 3 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 probe.ts create mode 100644 src/sim/behaviour-data.bounds.test.ts diff --git a/probe.ts b/probe.ts new file mode 100644 index 0000000..b525759 --- /dev/null +++ b/probe.ts @@ -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'); diff --git a/src/sim/behaviour-data.bounds.test.ts b/src/sim/behaviour-data.bounds.test.ts new file mode 100644 index 0000000..f207117 --- /dev/null +++ b/src/sim/behaviour-data.bounds.test.ts @@ -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): 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, +): 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); + }); +}); diff --git a/src/sim/behaviour-data.ts b/src/sim/behaviour-data.ts index 5ec120a..4acfe62 100644 --- a/src/sim/behaviour-data.ts +++ b/src/sim/behaviour-data.ts @@ -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 { @@ -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. */ @@ -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); @@ -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[] = []; @@ -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); @@ -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++) { @@ -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]];