From 5242b704bb84a26faff6a70754ad9b0c23f43aba Mon Sep 17 00:00:00 2001 From: Palash Vishwas Karmore Date: Wed, 28 Oct 2026 18:00:22 +0530 Subject: [PATCH] fix(sim): least-loaded pick ignores load on self-pooled kinds pickWeightedOrLeastLoaded measured state.busy plus the engine waiting list. Kinds that run their own slot pool and time work with serveWithin() (db, searchindex, timeseriesdb, graphdb, vectordb, replica, shard, coldstorage) leave both at 0, so every one of them looked idle and an equal-weight load balancer in front of them sent all traffic to the first. Add an optional load() behaviour hook, (in service + waiting) / slots, implemented by those kinds; the picker uses it when present and keeps the old formula otherwise. Pure read, no RNG, so determinism holds. --- src/sim/behaviour-data.ts | 22 +++++++++++ src/sim/behaviour-store.ts | 19 +++++++++ src/sim/behaviour.ts | 10 +++++ src/sim/engine.ts | 12 ++++-- src/sim/lbPooled.test.ts | 79 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/sim/lbPooled.test.ts 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')); + }); +});