Skip to content

Commit fbfac48

Browse files
authored
feat: Round robin pool (#4650)
1 parent 93df232 commit fbfac48

File tree

7 files changed

+689
-1
lines changed

7 files changed

+689
-1
lines changed

docs/docs/api/RoundRobinPool.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Class: RoundRobinPool
2+
3+
Extends: `undici.Dispatcher`
4+
5+
A pool of [Client](/docs/docs/api/Client.md) instances connected to the same upstream target with round-robin client selection.
6+
7+
Unlike [`Pool`](/docs/docs/api/Pool.md), which always selects the first available client, `RoundRobinPool` cycles through clients in a round-robin fashion. This ensures even distribution of requests across all connections, which is particularly useful when the upstream target is behind a load balancer that round-robins TCP connections across multiple backend servers (e.g., Kubernetes Services).
8+
9+
Requests are not guaranteed to be dispatched in order of invocation.
10+
11+
## `new RoundRobinPool(url[, options])`
12+
13+
Arguments:
14+
15+
* **url** `URL | string` - It should only include the **protocol, hostname, and port**.
16+
* **options** `RoundRobinPoolOptions` (optional)
17+
18+
### Parameter: `RoundRobinPoolOptions`
19+
20+
Extends: [`ClientOptions`](/docs/docs/api/Client.md#parameter-clientoptions)
21+
22+
* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Client(origin, opts)`
23+
* **connections** `number | null` (optional) - Default: `null` - The number of `Client` instances to create. When set to `null`, the `RoundRobinPool` instance will create an unlimited amount of `Client` instances.
24+
* **clientTtl** `number | null` (optional) - Default: `null` - The amount of time before a `Client` instance is removed from the `RoundRobinPool` and closed. When set to `null`, `Client` instances will not be removed or closed based on age.
25+
26+
## Use Case
27+
28+
`RoundRobinPool` is designed for scenarios where:
29+
30+
1. You connect to a single origin (e.g., `http://my-service.namespace.svc`)
31+
2. That origin is backed by a load balancer distributing TCP connections across multiple servers
32+
3. You want requests evenly distributed across all backend servers
33+
34+
**Example**: In Kubernetes, when using a Service DNS name with multiple Pod replicas, kube-proxy load balances TCP connections. `RoundRobinPool` ensures each connection (and thus each Pod) receives an equal share of requests.
35+
36+
### Important: Backend Distribution Considerations
37+
38+
`RoundRobinPool` distributes **HTTP requests** evenly across **TCP connections**. Whether this translates to even backend server distribution depends on the load balancer's behavior:
39+
40+
**✓ Works when the load balancer**:
41+
- Assigns different backends to different TCP connections from the same client
42+
- Uses algorithms like: round-robin, random, least-connections (without client affinity)
43+
- Example: Default Kubernetes Services without `sessionAffinity`
44+
45+
**✗ Does NOT work when**:
46+
- Load balancer has client/source IP affinity (all connections from one IP → same backend)
47+
- Load balancer uses source-IP-hash or sticky sessions
48+
49+
**How it works:**
50+
1. `RoundRobinPool` creates N TCP connections to the load balancer endpoint
51+
2. Load balancer assigns each TCP connection to a backend (per its algorithm)
52+
3. `RoundRobinPool` cycles HTTP requests across those N connections
53+
4. Result: Requests distributed proportionally to how the LB distributed the connections
54+
55+
If the load balancer assigns all connections to the same backend (e.g., due to session affinity), `RoundRobinPool` cannot overcome this. In such cases, consider using [`BalancedPool`](/docs/docs/api/BalancedPool.md) with direct backend addresses (e.g., individual pod IPs) instead of a load-balanced endpoint.
56+
57+
## Instance Properties
58+
59+
### `RoundRobinPool.closed`
60+
61+
Implements [Client.closed](/docs/docs/api/Client.md#clientclosed)
62+
63+
### `RoundRobinPool.destroyed`
64+
65+
Implements [Client.destroyed](/docs/docs/api/Client.md#clientdestroyed)
66+
67+
### `RoundRobinPool.stats`
68+
69+
Returns [`PoolStats`](PoolStats.md) instance for this pool.
70+
71+
## Instance Methods
72+
73+
### `RoundRobinPool.close([callback])`
74+
75+
Implements [`Dispatcher.close([callback])`](/docs/docs/api/Dispatcher.md#dispatcherclosecallback-promise).
76+
77+
### `RoundRobinPool.destroy([error, callback])`
78+
79+
Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise).
80+
81+
### `RoundRobinPool.connect(options[, callback])`
82+
83+
See [`Dispatcher.connect(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback).
84+
85+
### `RoundRobinPool.dispatch(options, handler)`
86+
87+
Implements [`Dispatcher.dispatch(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler).
88+
89+
### `RoundRobinPool.pipeline(options, handler)`
90+
91+
See [`Dispatcher.pipeline(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler).
92+
93+
### `RoundRobinPool.request(options[, callback])`
94+
95+
See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback).
96+
97+
### `RoundRobinPool.stream(options, factory[, callback])`
98+
99+
See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback).
100+
101+
### `RoundRobinPool.upgrade(options[, callback])`
102+
103+
See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback).
104+
105+
## Instance Events
106+
107+
### Event: `'connect'`
108+
109+
See [Dispatcher Event: `'connect'`](/docs/docs/api/Dispatcher.md#event-connect).
110+
111+
### Event: `'disconnect'`
112+
113+
See [Dispatcher Event: `'disconnect'`](/docs/docs/api/Dispatcher.md#event-disconnect).
114+
115+
### Event: `'drain'`
116+
117+
See [Dispatcher Event: `'drain'`](/docs/docs/api/Dispatcher.md#event-drain).
118+
119+
## Example
120+
121+
```javascript
122+
import { RoundRobinPool } from 'undici'
123+
124+
const pool = new RoundRobinPool('http://my-service.default.svc.cluster.local', {
125+
connections: 10
126+
})
127+
128+
// Requests will be distributed evenly across all 10 connections
129+
for (let i = 0; i < 100; i++) {
130+
const { body } = await pool.request({
131+
path: '/api/data',
132+
method: 'GET'
133+
})
134+
console.log(await body.json())
135+
}
136+
137+
await pool.close()
138+
```
139+
140+
## See Also
141+
142+
- [Pool](/docs/docs/api/Pool.md) - Connection pool without round-robin
143+
- [BalancedPool](/docs/docs/api/BalancedPool.md) - Load balancing across multiple origins
144+
- [Issue #3648](https://github.com/nodejs/undici/issues/3648) - Original issue describing uneven distribution
145+

docs/docsify/sidebar.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* [H2CClient](/docs/api/H2CClient.md "Undici H2C API - Client")
88
* [Pool](/docs/api/Pool.md "Undici API - Pool")
99
* [BalancedPool](/docs/api/BalancedPool.md "Undici API - BalancedPool")
10+
* [RoundRobinPool](/docs/api/RoundRobinPool.md "Undici API - RoundRobinPool")
1011
* [Agent](/docs/api/Agent.md "Undici API - Agent")
1112
* [ProxyAgent](/docs/api/ProxyAgent.md "Undici API - ProxyAgent")
1213
* [RetryAgent](/docs/api/RetryAgent.md "Undici API - RetryAgent")

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const Client = require('./lib/dispatcher/client')
44
const Dispatcher = require('./lib/dispatcher/dispatcher')
55
const Pool = require('./lib/dispatcher/pool')
66
const BalancedPool = require('./lib/dispatcher/balanced-pool')
7+
const RoundRobinPool = require('./lib/dispatcher/round-robin-pool')
78
const Agent = require('./lib/dispatcher/agent')
89
const ProxyAgent = require('./lib/dispatcher/proxy-agent')
910
const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent')
@@ -31,6 +32,7 @@ module.exports.Dispatcher = Dispatcher
3132
module.exports.Client = Client
3233
module.exports.Pool = Pool
3334
module.exports.BalancedPool = BalancedPool
35+
module.exports.RoundRobinPool = RoundRobinPool
3436
module.exports.Agent = Agent
3537
module.exports.ProxyAgent = ProxyAgent
3638
module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent

lib/dispatcher/round-robin-pool.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use strict'
2+
3+
const {
4+
PoolBase,
5+
kClients,
6+
kNeedDrain,
7+
kAddClient,
8+
kGetDispatcher,
9+
kRemoveClient
10+
} = require('./pool-base')
11+
const Client = require('./client')
12+
const {
13+
InvalidArgumentError
14+
} = require('../core/errors')
15+
const util = require('../core/util')
16+
const { kUrl } = require('../core/symbols')
17+
const buildConnector = require('../core/connect')
18+
19+
const kOptions = Symbol('options')
20+
const kConnections = Symbol('connections')
21+
const kFactory = Symbol('factory')
22+
const kIndex = Symbol('index')
23+
24+
function defaultFactory (origin, opts) {
25+
return new Client(origin, opts)
26+
}
27+
28+
class RoundRobinPool extends PoolBase {
29+
constructor (origin, {
30+
connections,
31+
factory = defaultFactory,
32+
connect,
33+
connectTimeout,
34+
tls,
35+
maxCachedSessions,
36+
socketPath,
37+
autoSelectFamily,
38+
autoSelectFamilyAttemptTimeout,
39+
allowH2,
40+
clientTtl,
41+
...options
42+
} = {}) {
43+
if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
44+
throw new InvalidArgumentError('invalid connections')
45+
}
46+
47+
if (typeof factory !== 'function') {
48+
throw new InvalidArgumentError('factory must be a function.')
49+
}
50+
51+
if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') {
52+
throw new InvalidArgumentError('connect must be a function or an object')
53+
}
54+
55+
if (typeof connect !== 'function') {
56+
connect = buildConnector({
57+
...tls,
58+
maxCachedSessions,
59+
allowH2,
60+
socketPath,
61+
timeout: connectTimeout,
62+
...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
63+
...connect
64+
})
65+
}
66+
67+
super()
68+
69+
this[kConnections] = connections || null
70+
this[kUrl] = util.parseOrigin(origin)
71+
this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl }
72+
this[kOptions].interceptors = options.interceptors
73+
? { ...options.interceptors }
74+
: undefined
75+
this[kFactory] = factory
76+
this[kIndex] = -1
77+
78+
this.on('connect', (origin, targets) => {
79+
if (clientTtl != null && clientTtl > 0) {
80+
for (const target of targets) {
81+
Object.assign(target, { ttl: Date.now() })
82+
}
83+
}
84+
})
85+
86+
this.on('connectionError', (origin, targets, error) => {
87+
for (const target of targets) {
88+
const idx = this[kClients].indexOf(target)
89+
if (idx !== -1) {
90+
this[kClients].splice(idx, 1)
91+
}
92+
}
93+
})
94+
}
95+
96+
[kGetDispatcher] () {
97+
const clientTtlOption = this[kOptions].clientTtl
98+
const clientsLength = this[kClients].length
99+
100+
// If we have no clients yet, create one
101+
if (clientsLength === 0) {
102+
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
103+
this[kAddClient](dispatcher)
104+
return dispatcher
105+
}
106+
107+
// Round-robin through existing clients
108+
let checked = 0
109+
while (checked < clientsLength) {
110+
this[kIndex] = (this[kIndex] + 1) % clientsLength
111+
const client = this[kClients][this[kIndex]]
112+
113+
// Check if client is stale (TTL expired)
114+
if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
115+
this[kRemoveClient](client)
116+
checked++
117+
continue
118+
}
119+
120+
// Return client if it's not draining
121+
if (!client[kNeedDrain]) {
122+
return client
123+
}
124+
125+
checked++
126+
}
127+
128+
// All clients are busy, create a new one if we haven't reached the limit
129+
if (!this[kConnections] || clientsLength < this[kConnections]) {
130+
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
131+
this[kAddClient](dispatcher)
132+
return dispatcher
133+
}
134+
}
135+
}
136+
137+
module.exports = RoundRobinPool

0 commit comments

Comments
 (0)