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
22 changes: 22 additions & 0 deletions src/sim/behaviour-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions src/sim/behaviour-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -410,6 +424,7 @@ const db: ComponentBehaviour = {
},

onTick: reportPool,
load: poolLoad,

decorateStats(ctx, state, stats: NodeStats): void {
decoratePool(state, stats);
Expand Down Expand Up @@ -513,6 +528,7 @@ const searchindex: ComponentBehaviour = {
},

onTick: reportPool,
load: poolLoad,

decorateStats(ctx, state, stats): void {
decoratePool(state, stats);
Expand Down Expand Up @@ -571,6 +587,7 @@ const timeseriesdb: ComponentBehaviour = {
},

onTick: reportPool,
load: poolLoad,

decorateStats(ctx, state, stats): void {
decoratePool(state, stats);
Expand Down Expand Up @@ -620,6 +637,7 @@ const graphdb: ComponentBehaviour = {
},

onTick: reportPool,
load: poolLoad,

decorateStats(ctx, state, stats): void {
decoratePool(state, stats);
Expand Down Expand Up @@ -669,6 +687,7 @@ const vectordb: ComponentBehaviour = {
},

onTick: reportPool,
load: poolLoad,

decorateStats(ctx, state, stats): void {
decoratePool(state, stats);
Expand Down
10 changes: 10 additions & 0 deletions src/sim/behaviour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions src/sim/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
79 changes: 79 additions & 0 deletions src/sim/lbPooled.test.ts
Original file line number Diff line number Diff line change
@@ -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<NodeConfig>) => ({
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<NodeConfig> = {
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'));
});
});