diff --git a/src/sim/behaviour-data.ts b/src/sim/behaviour-data.ts index 4acfe62..622e3a5 100644 --- a/src/sim/behaviour-data.ts +++ b/src/sim/behaviour-data.ts @@ -191,6 +191,16 @@ const replica: ComponentBehaviour = { ctx.reportInstances(state, out, 0); }, + load(_ctx: BehaviourCtx, state: NodeStateLike): number { + const ext = replicaExt(state); + const work = + ext.readBusy + + ext.writeBusy + + (ext.readQueue.length - ext.readHead) + + (ext.writeQueue.length - ext.writeHead); + return work / (readCapacity(state) + writeCapacity(state)); + }, + onTick(ctx: BehaviourCtx, state: NodeStateLike, _dtMs: number): void { // Publish occupancy on the engine's clock. Reads and writes draw from two // different pools held in `ext`, so state.busy stays 0 and the engine @@ -453,6 +463,18 @@ const shard: ComponentBehaviour = { return 'handled'; }, + load(_ctx: BehaviourCtx, state: NodeStateLike): number { + // Not ensureSized(): this must be a pure read, and a pending resize is + // applied on the next admit anyway. + const ext = shardExt(state); + let work = 0; + for (let i = 0; i < ext.sized; i++) { + work += ext.busy[i] + (ext.queues[i].length - ext.heads[i]); + } + const capacity = clampInt(state.config.shardCapacity, 1, MAX_SHARD_CAPACITY); + return work / (ext.sized * capacity); + }, + onTick(ctx: BehaviourCtx, state: NodeStateLike, _dtMs: number): void { // Integrate per-shard occupancy on the engine's own clock rather than per // event, so an idle shard's utilisation still decays toward zero. diff --git a/src/sim/behaviour-store.ts b/src/sim/behaviour-store.ts index 843c795..e507875 100644 --- a/src/sim/behaviour-store.ts +++ b/src/sim/behaviour-store.ts @@ -203,6 +203,11 @@ const coldstorage: ComponentBehaviour = { return 'handled'; }, + load(ctx, state): number { + const ext = state.ext as ColdStorageExt; + return ext.jobs / Math.max(1, ctx.effectiveCapacity(state)); + }, + onTick(ctx, state): void { const ext = state.ext as ColdStorageExt; ctx.reportOccupancy(state, ext.jobs, ctx.effectiveCapacity(state)); @@ -323,6 +328,15 @@ function reportPool(ctx: BehaviourCtx, state: NodeStateLike): void { ctx.reportOccupancy(state, poolExt(state).busy, ctx.effectiveCapacity(state)); } +/** Busy plus queued over slots, for the least-loaded edge picker. */ +function poolLoad(ctx: BehaviourCtx, state: NodeStateLike): number { + const ext = poolExt(state); + return ( + (ext.busy + (ext.queue.length - ext.head)) / + Math.max(1, ctx.effectiveCapacity(state)) + ); +} + function decoratePool(state: NodeStateLike, stats: NodeStats): void { const ext = poolExt(state); stats.inFlight = ext.busy; @@ -410,6 +424,7 @@ const db: ComponentBehaviour = { }, onTick: reportPool, + load: poolLoad, decorateStats(ctx, state, stats: NodeStats): void { decoratePool(state, stats); @@ -513,6 +528,7 @@ const searchindex: ComponentBehaviour = { }, onTick: reportPool, + load: poolLoad, decorateStats(ctx, state, stats): void { decoratePool(state, stats); @@ -571,6 +587,7 @@ const timeseriesdb: ComponentBehaviour = { }, onTick: reportPool, + load: poolLoad, decorateStats(ctx, state, stats): void { decoratePool(state, stats); @@ -620,6 +637,7 @@ const graphdb: ComponentBehaviour = { }, onTick: reportPool, + load: poolLoad, decorateStats(ctx, state, stats): void { decoratePool(state, stats); @@ -669,6 +687,7 @@ const vectordb: ComponentBehaviour = { }, onTick: reportPool, + load: poolLoad, decorateStats(ctx, state, stats): void { decoratePool(state, stats); diff --git a/src/sim/behaviour.ts b/src/sim/behaviour.ts index 49b3474..1b83135 100644 --- a/src/sim/behaviour.ts +++ b/src/sim/behaviour.ts @@ -135,6 +135,16 @@ export interface ComponentBehaviour { */ noRouteReason?: FailureReason; + /** + * How full this node is, as (in service + waiting) / slots, for a kind that + * runs its own slot pool in `ext`. The least-loaded edge picker reads + * `state.busy` and the engine's waiting list, which such a kind leaves at + * zero, so without this every one of them looks idle and a load balancer + * sends all traffic to the first. Absent for kinds the engine already + * measures. Must be a pure read: no RNG, no state change. + */ + load?(ctx: BehaviourCtx, state: NodeStateLike): number; + /** * Which NodeConfig knob a controller moves to make this kind BIGGER, and * which the engine reads back to say how big it currently is. diff --git a/src/sim/engine.ts b/src/sim/engine.ts index 2964810..f7eedc1 100644 --- a/src/sim/engine.ts +++ b/src/sim/engine.ts @@ -1463,9 +1463,15 @@ export class Engine implements BehaviourCtx { if (anyCut && cut.has(out[i].id)) continue; const target = this.nodes.get(out[i].to); if (!target) continue; - const depth = target.busy + (target.waiting.length - target.waitHead); - const cap = target.config.capacity > 0 ? target.config.capacity : 1; - const load = depth / cap; + const own = target.behaviour.load; + let load: number; + if (own) { + load = own(this, target); + } else { + const depth = target.busy + (target.waiting.length - target.waitHead); + const cap = target.config.capacity > 0 ? target.config.capacity : 1; + load = depth / cap; + } if (load < bestLoad) { bestLoad = load; best = out[i]; diff --git a/src/sim/lbPooled.test.ts b/src/sim/lbPooled.test.ts new file mode 100644 index 0000000..69b3d40 --- /dev/null +++ b/src/sim/lbPooled.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { Engine } from './engine'; +import { defaultConfig } from './presets'; +import type { NodeConfig, NodeKind, Topology } from './types'; + +const node = (id: string, kind: NodeKind, o: Partial) => ({ + id, + kind, + label: id, + x: 0, + y: 0, + config: { ...defaultConfig(kind), ...o }, +}); + +// Kinds that run their own slot pool (state.busy stays 0) next to a plain +// service as the control. +const KINDS: readonly NodeKind[] = [ + 'service', + 'db', + 'searchindex', + 'timeseriesdb', + 'graphdb', + 'vectordb', + 'replica', + 'shard', + 'coldstorage', +]; + +function arrivalsBehindLb(kind: NodeKind): number[] { + const store: Partial = { + capacity: 4, + shardCapacity: 4, + shardCount: 1, + replicaCount: 1, + readFraction: 1, + instances: 1, + serviceMs: 20, + serviceCv: 0, + queueLimit: 64, + lockMs: 0, + // vectordb: no index-size or recall surcharge, so serviceMs is the cost. + indexSizeK: 0, + recallTarget: 0, + }; + const t: Topology = { + nodes: [ + node('client', 'client', { rps: 300, timeoutMs: 5000 }), + node('lb', 'lb', { capacity: 1000, serviceMs: 0, queueLimit: 1000 }), + node('s0', kind, store), + node('s1', kind, store), + node('s2', kind, store), + ], + edges: [ + { id: 'c->lb', from: 'client', to: 'lb', weight: 1 }, + { id: 'lb->s0', from: 'lb', to: 's0', weight: 1 }, + { id: 'lb->s1', from: 'lb', to: 's1', weight: 1 }, + { id: 'lb->s2', from: 'lb', to: 's2', weight: 1 }, + ], + }; + const e = new Engine(t, 1); + for (let i = 0; i < 600; i++) e.advance(1000 / 60); + const n = e.snapshot().nodes; + return ['s0', 's1', 's2'].map((id) => n[id]!.arrivalRate); +} + +describe('least-loaded lb in front of pooled kinds', () => { + for (const kind of KINDS) { + it(`spreads load across ${kind} nodes`, () => { + const rates = arrivalsBehindLb(kind); + // 300 rps over three nodes whose ceiling is 4 x 1000/20 = 200 rps each: + // one node alone cannot keep up, so every node must get real traffic. + for (const r of rates) expect(r, rates.join()).toBeGreaterThan(50); + }); + } + + it('stays deterministic', () => { + expect(arrivalsBehindLb('db')).toEqual(arrivalsBehindLb('db')); + }); +});