diff --git a/src/serve/index.ts b/src/serve/index.ts index 247066e..df3c09a 100644 --- a/src/serve/index.ts +++ b/src/serve/index.ts @@ -1,4 +1,11 @@ -export type { ListenArgs, ListenOptions, Balance, ServerThreads, ServerThreadsOptions } from './types.ts'; +export type { + ListenArgs, + ListenOptions, + ServerThread, + ServerThreadBalancer, + ServerThreads, + ServerThreadsOptions, +} from './types.ts'; export { leastConns, roundRobin } from './strategies.ts'; export { listen } from './listen.ts'; export { serverThreads } from './server-threads.ts'; diff --git a/src/serve/server-threads.ts b/src/serve/server-threads.ts index 3b1e486..273f801 100644 --- a/src/serve/server-threads.ts +++ b/src/serve/server-threads.ts @@ -6,7 +6,7 @@ import { int32atomic } from '../shared/descriptors.ts'; import type { WorkerHandle } from '../runner.ts'; import { PushChannel } from './push-channel.ts'; import { leastConns } from './strategies.ts'; -import type { ListenArgs, ListenOptions, ServerThreads, ServerThreadsOptions } from './types.ts'; +import type { ListenArgs, ListenOptions, ServerThread, ServerThreads, ServerThreadsOptions } from './types.ts'; const DEFAULT_HIGH_WATER_MARK = 64; const DEFAULT_DRAIN_TIMEOUT = 30_000; @@ -40,10 +40,11 @@ export function serverThreads( socket.destroy(); return; } - // Snapshot counters, pick worker. - const snapshot: number[] = new Array(n); - for (let i = 0; i < n; i++) snapshot[i] = counters.elements[i].load(); - const idx = balance.pick(snapshot); + // Snapshot per-worker state, pick target. + const threads: ServerThread[] = new Array(n); + for (let i = 0; i < n; i++) threads[i] = { worker: workers[i], conns: counters.elements[i].load() }; + const selected = balance.select(threads); + const idx = workers.indexOf(selected.worker); // Dup the fd via /dev/fd so the new descriptor is independent of libuv's // event loop tracking. This lets socket.destroy() properly close the diff --git a/src/serve/strategies.ts b/src/serve/strategies.ts index db1ef5e..de56bfc 100644 --- a/src/serve/strategies.ts +++ b/src/serve/strategies.ts @@ -1,30 +1,28 @@ -import type { Balance } from './types.ts'; +import type { ServerThreadBalancer } from './types.ts'; -/** Pick the worker with the fewest active connections; ties go to the lowest index. */ -export function leastConns(): Balance { +/** Pick the thread with the fewest active connections; ties go to the first. */ +export function leastConns(): ServerThreadBalancer { 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]; + select(threads) { + let best = threads[0]; + for (let i = 1; i < threads.length; i++) { + if (threads[i].conns < best.conns) { + best = threads[i]; } } - return bestIdx; + return best; }, }; } -/** Cycle through worker indices in order, ignoring counts. */ -export function roundRobin(): Balance { +/** Cycle through threads in order, ignoring connection counts. */ +export function roundRobin(): ServerThreadBalancer { let cursor = 0; return { - pick(counters) { - const idx = cursor % counters.length; - cursor = (cursor + 1) % counters.length; - return idx; + select(threads) { + const idx = cursor % threads.length; + cursor = (cursor + 1) % threads.length; + return threads[idx]; }, }; } diff --git a/src/serve/types.ts b/src/serve/types.ts index 84a1ea3..f61a288 100644 --- a/src/serve/types.ts +++ b/src/serve/types.ts @@ -13,14 +13,20 @@ export interface ListenOptions { */ export type ListenArgs = readonly [fds: AsyncIterable, counter: Int32Atomic, opts: Required]; -/** Connection-routing strategy. Picks a worker index given a counter snapshot. */ -export interface Balance { - pick(counters: readonly number[]): number; +/** Per-worker state snapshot passed to the balancer. */ +export interface ServerThread { + worker: WorkerHandle; + conns: number; +} + +/** Connection-routing strategy. Selects a thread to receive the next connection. */ +export interface ServerThreadBalancer { + select(threads: readonly ServerThread[]): ServerThread; } export interface ServerThreadsOptions { /** Routing strategy. Default: `leastConns()`. */ - balance?: Balance; + balance?: ServerThreadBalancer; /** Options forwarded to each worker's `listen()` call. */ listen?: ListenOptions; /** Per-worker fd-channel buffer size. Default 64. */ diff --git a/test/serve/strategies.test.ts b/test/serve/strategies.test.ts index e6dd607..10383e1 100644 --- a/test/serve/strategies.test.ts +++ b/test/serve/strategies.test.ts @@ -1,43 +1,54 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { leastConns, roundRobin } from 'moroutine/serve'; +import type { ServerThread } from 'moroutine/serve'; + +function threads(...conns: number[]): ServerThread[] { + return conns.map((c, i) => ({ worker: { id: i } as any, conns: c })); +} describe('leastConns', () => { - it('picks the index with the lowest count', () => { + it('picks the thread 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); + const t = threads(5, 2, 7); + assert.strictEqual(b.select(t), t[1]); + const t2 = threads(0, 0, 0); + assert.strictEqual(b.select(t2), t2[0]); // ties → first + const t3 = threads(3, 3, 1); + assert.strictEqual(b.select(t3), t3[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 + const t = threads(1, 0, 0); + assert.strictEqual(b.select(t), t[1]); + assert.strictEqual(b.select(t), t[1]); // same input → same result }); }); describe('roundRobin', () => { - it('cycles through indices in order', () => { + it('cycles through threads 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); + const t = threads(0, 0, 0, 0); + assert.strictEqual(b.select(t), t[0]); + assert.strictEqual(b.select(t), t[1]); + assert.strictEqual(b.select(t), t[2]); + assert.strictEqual(b.select(t), t[3]); + assert.strictEqual(b.select(t), t[0]); }); - it('ignores counter values', () => { + it('ignores conns 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); + const t = threads(100, 0, 0); + assert.strictEqual(b.select(t), t[0]); // does not pick the idle one + assert.strictEqual(b.select(t), t[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 + const t = threads(0, 0); + b1.select(t); // b1 cursor: 1 + assert.strictEqual(b2.select(t), t[0]); // b2 starts fresh }); });