From 616cbd1d6be3740546e9abde9f1e6392c55ef4fe Mon Sep 17 00:00:00 2001 From: Nawazish Khan <175596916+nawazish2@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:52:21 +0530 Subject: [PATCH] fix: load balancer admits through its pool instead of passing everything passthru skipped capacity, instances and queueLimit, so an lb had no ceiling. Co-authored-by: Cursor --- src/sim/behaviour-control.ts | 2 +- src/sim/behaviour.ts | 6 ++- src/sim/engine.test.ts | 74 ++++++++++++++++++++++++++++++++++++ src/sim/engine.ts | 2 +- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/sim/behaviour-control.ts b/src/sim/behaviour-control.ts index 8654079..973713c 100644 --- a/src/sim/behaviour-control.ts +++ b/src/sim/behaviour-control.ts @@ -434,7 +434,7 @@ function regionHealthy(ctx: BehaviourCtx, state: NodeStateLike, i: number): bool */ const region: ComponentBehaviour = { kind: 'region', - // A pure switch, like an lb: it forwards without holding slots of its own. + // A pure switch: it forwards without holding slots of its own. servesRequests: true, generatesLoad: false, pullsFromQueues: false, diff --git a/src/sim/behaviour.ts b/src/sim/behaviour.ts index 7df4740..f6df0bb 100644 --- a/src/sim/behaviour.ts +++ b/src/sim/behaviour.ts @@ -287,6 +287,11 @@ const client: ComponentBehaviour = { /** * A dispatcher. Picks exactly one downstream per request: weighted-random when * the edge weights differ, least-loaded when they are all equal. + * + * Its pool is real. There is no onAdmit, so the engine's default `serve` puts + * `capacity`, `instances` and `queueLimit` through ordinary slot and queue + * discipline. `passthru` skipped that, which made all three knobs inert and + * left an autoscaler writing `instances` with nothing to turn (#52). */ const lb: ComponentBehaviour = { kind: 'lb', @@ -298,7 +303,6 @@ const lb: ComponentBehaviour = { buffersForConsumers: false, pump: 'own', creditsJoinCompletion: true, - onAdmit: () => 'passthru', route: () => 'one', pickEdge: (ctx, _state, _req, out) => ctx.pickWeightedOrLeastLoaded(out), }; diff --git a/src/sim/engine.test.ts b/src/sim/engine.test.ts index 330ce1a..a014be4 100644 --- a/src/sim/engine.test.ts +++ b/src/sim/engine.test.ts @@ -203,6 +203,80 @@ describe('load response', () => { }); }); +/** + * A load balancer's pool used to be theatre: onAdmit returned passthru, so + * capacity, instances and queueLimit never met an admission check (#52). + * + * The ceiling is AGENTS.md's sizing rule, capacity * instances * (1000 / + * serviceMs). The service behind the lb is deliberately far above that, so + * if throughput still ignores the pool, the lb is the thing that is lying. + */ +describe('load balancer pool', () => { + function topology(lb: { + capacity: number; + instances: number; + queueLimit: number; + serviceMs: number; + }): Topology { + const client = makeNode('client', 0, 0); + client.id = 'client'; + client.config = { ...defaultConfig('client'), rps: 3000, timeoutMs: 10_000 }; + + const balancer = makeNode('lb', 260, 0); + balancer.id = 'lb'; + balancer.config = { ...defaultConfig('lb'), ...lb, serviceCv: 0 }; + + const service = makeNode('service', 520, 0); + service.id = 'service'; + service.config = { + ...defaultConfig('service'), + capacity: 64, + instances: 8, + serviceMs: 8, + queueLimit: 10_000, + }; + + return { + nodes: [client, balancer, service], + edges: [ + { id: 'e1', from: 'client', to: 'lb', weight: 1 }, + { id: 'e2', from: 'lb', to: 'service', weight: 1 }, + ], + }; + } + + it('caps throughput at the pool ceiling instead of passing every request', () => { + const snapshot = run( + topology({ capacity: 2, instances: 1, queueLimit: 4, serviceMs: 5 }), + 30, + ); + const ceiling = 2 * 1 * (1000 / 5); + expect(snapshot.nodes.lb.throughput).toBeLessThanOrEqual(ceiling * 1.35); + expect(snapshot.system.offeredRps).toBeGreaterThan(ceiling * 2); + expect(snapshot.nodes.lb.shedRate).toBeGreaterThan(0); + }); + + it('lets instances raise that ceiling', () => { + const tight = run( + topology({ capacity: 2, instances: 1, queueLimit: 4, serviceMs: 5 }), + 30, + ); + const wide = run( + topology({ capacity: 2, instances: 12, queueLimit: 4, serviceMs: 5 }), + 30, + ); + expect(wide.nodes.lb.throughput).toBeGreaterThan(tight.nodes.lb.throughput * 2); + }); + + it('queues when the pool is full, instead of keeping waiting at zero', () => { + const snapshot = run( + topology({ capacity: 2, instances: 1, queueLimit: 4096, serviceMs: 5 }), + 10, + ); + expect(snapshot.nodes.lb.queued).toBeGreaterThan(0); + }); +}); + describe('hostile topologies', () => { const cases: Array<[string, Topology]> = [ ['empty', { nodes: [], edges: [] }], diff --git a/src/sim/engine.ts b/src/sim/engine.ts index 4d99643..9357df6 100644 --- a/src/sim/engine.ts +++ b/src/sim/engine.ts @@ -1715,7 +1715,7 @@ export class Engine implements BehaviourCtx { } } - /** lb / client style pass-through with a tiny (possibly zero) service time. */ + /** Client-style pass-through with a tiny (possibly zero) service time. */ private beginZeroService(state: NodeState, req: Req): void { const ms = this.serviceTimeFor(state); req.ownMs = ms;