From 4c74710aa0c9827678a3f625cf77538f1021bd35 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 24 Apr 2026 17:18:36 -0400 Subject: [PATCH] feat(serve): leastConns and roundRobin balance strategies --- src/serve/index.ts | 1 + src/serve/strategies.ts | 30 ++++++++++++++++++++++++ test/serve/strategies.test.ts | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 src/serve/strategies.ts create mode 100644 test/serve/strategies.test.ts diff --git a/src/serve/index.ts b/src/serve/index.ts index 8489f72..fc44d2a 100644 --- a/src/serve/index.ts +++ b/src/serve/index.ts @@ -1 +1,2 @@ export type { ListenArgs, ListenOptions, Balance, ServerThreads, ServerThreadsOptions } from './types.ts'; +export { leastConns, roundRobin } from './strategies.ts'; diff --git a/src/serve/strategies.ts b/src/serve/strategies.ts new file mode 100644 index 0000000..db1ef5e --- /dev/null +++ b/src/serve/strategies.ts @@ -0,0 +1,30 @@ +import type { Balance } from './types.ts'; + +/** Pick the worker with the fewest active connections; ties go to the lowest index. */ +export function leastConns(): Balance { + return { + pick(counters) { + let bestIdx = 0; + let bestCount = counters[0]; + for (let i = 1; i < counters.length; i++) { + if (counters[i] < bestCount) { + bestIdx = i; + bestCount = counters[i]; + } + } + return bestIdx; + }, + }; +} + +/** Cycle through worker indices in order, ignoring counts. */ +export function roundRobin(): Balance { + let cursor = 0; + return { + pick(counters) { + const idx = cursor % counters.length; + cursor = (cursor + 1) % counters.length; + return idx; + }, + }; +} diff --git a/test/serve/strategies.test.ts b/test/serve/strategies.test.ts new file mode 100644 index 0000000..e6dd607 --- /dev/null +++ b/test/serve/strategies.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { leastConns, roundRobin } from 'moroutine/serve'; + +describe('leastConns', () => { + it('picks the index with the lowest count', () => { + const b = leastConns(); + assert.equal(b.pick([5, 2, 7]), 1); + assert.equal(b.pick([0, 0, 0]), 0); // ties → first min + assert.equal(b.pick([3, 3, 1]), 2); + }); + + it('is stateless across calls', () => { + const b = leastConns(); + assert.equal(b.pick([1, 0, 0]), 1); + assert.equal(b.pick([1, 0, 0]), 1); // same input → same result + }); +}); + +describe('roundRobin', () => { + it('cycles through indices in order', () => { + const b = roundRobin(); + const counters = [0, 0, 0, 0]; + assert.equal(b.pick(counters), 0); + assert.equal(b.pick(counters), 1); + assert.equal(b.pick(counters), 2); + assert.equal(b.pick(counters), 3); + assert.equal(b.pick(counters), 0); + }); + + it('ignores counter values', () => { + const b = roundRobin(); + assert.equal(b.pick([100, 0, 0]), 0); // does not pick the idle one + assert.equal(b.pick([0, 100, 0]), 1); + }); + + it('each instance has independent state', () => { + const b1 = roundRobin(); + const b2 = roundRobin(); + b1.pick([0, 0]); // b1 cursor: 1 + assert.equal(b2.pick([0, 0]), 0); // b2 starts fresh + }); +}); -- 2.51.2