Skip to content

Commit b3c837f

Browse files
committed
feat: Enhance load balancing strategies with power-of-two-choices, latency, and weighted least connections; update README for new features
1 parent ba915fd commit b3c837f

5 files changed

Lines changed: 132 additions & 13 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
- **🔥 Blazing Fast**: Built on Bun - up to 4x faster than Node.js alternatives
1717
- **🎯 Zero Config**: Works out of the box with sensible defaults
18-
- **🧠 Smart Load Balancing**: 5 load balancing algorithms: `round-robin`, `least-connections`, `random`, `weighted`, `ip-hash`
18+
- **🧠 Smart Load Balancing**: Multiple algorithms: `round-robin`, `least-connections`, `random`, `weighted`, `ip-hash`, `p2c` (power-of-two-choices), `latency`, `weighted-least-connections`
1919
- **🛡️ Production Ready**: Circuit breakers, health checks, and auto-failover
2020
- **🔐 Built-in Authentication**: JWT, API keys, JWKS, and OAuth2 support out of the box
2121
- **🎨 Developer Friendly**: Full TypeScript support with intuitive APIs
@@ -107,6 +107,9 @@ console.log('🚀 Bungate running on http://localhost:3000')
107107
- **Least Connections**: Route to the least busy server
108108
- **IP Hash**: Consistent routing based on client IP for session affinity
109109
- **Random**: Randomized distribution for even load
110+
- **Power of Two Choices (p2c)**: Pick the better of two random targets by load/latency
111+
- **Latency**: Prefer the target with the lowest average response time
112+
- **Weighted Least Connections**: Prefer targets with fewer connections normalized by weight
110113
- **Sticky Sessions**: Session affinity with cookie-based persistence
111114

112115
### 🛡️ **Reliability & Resilience**

src/interfaces/load-balancer.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export interface LoadBalancerConfig {
6969
| 'random' // Randomly selects a target
7070
| 'weighted' // Uses target weights for distribution
7171
| 'ip-hash' // Routes based on client IP hash for session affinity
72+
| 'p2c' // Power of two choices: pick best of two random targets
73+
| 'power-of-two-choices' // Alias for p2c
74+
| 'latency' // Chooses target with the lowest avg response time
75+
| 'weighted-least-connections' // Least connections normalized by weight
7276

7377
/**
7478
* List of backend targets to load balance across
@@ -113,6 +117,11 @@ export interface LoadBalancerConfig {
113117
* @example 'OK' or 'healthy'
114118
*/
115119
expectedBody?: string
120+
/**
121+
* HTTP method to use for health checks
122+
* @default 'GET'
123+
*/
124+
method?: 'GET' | 'HEAD'
116125
}
117126

118127
/**

src/load-balancer/http-load-balancer.ts

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ export class HttpLoadBalancer implements LoadBalancer {
151151
return null
152152
}
153153

154+
// Fast path: only one healthy target
155+
if (healthyTargets.length === 1) {
156+
const only = healthyTargets[0]!
157+
this.recordRequest(only.url)
158+
this.logger.logLoadBalancing(this.config.strategy, only.url, {
159+
reason: 'single-healthy',
160+
duration: Date.now() - startTime,
161+
healthyTargets: 1,
162+
})
163+
return only
164+
}
165+
154166
// Check for sticky session first
155167
if (this.config.stickySession?.enabled) {
156168
const stickyTarget = this.getStickyTarget(request)
@@ -184,6 +196,16 @@ export class HttpLoadBalancer implements LoadBalancer {
184196
case 'ip-hash':
185197
selectedTarget = this.selectIpHash(request, healthyTargets)
186198
break
199+
case 'p2c':
200+
case 'power-of-two-choices':
201+
selectedTarget = this.selectPowerOfTwoChoices(healthyTargets)
202+
break
203+
case 'latency':
204+
selectedTarget = this.selectByLatency(healthyTargets)
205+
break
206+
case 'weighted-least-connections':
207+
selectedTarget = this.selectWeightedLeastConnections(healthyTargets)
208+
break
187209
default:
188210
selectedTarget = this.selectRoundRobin(healthyTargets)
189211
}
@@ -378,7 +400,8 @@ export class HttpLoadBalancer implements LoadBalancer {
378400
const target = this.targets.get(url)
379401
if (target) {
380402
target.totalResponseTime += responseTime
381-
target.averageResponseTime = target.totalResponseTime / target.requests
403+
const reqs = target.requests || 1 // avoid division by zero before first selectTarget
404+
target.averageResponseTime = target.totalResponseTime / reqs
382405

383406
if (isError) {
384407
target.errors++
@@ -478,6 +501,69 @@ export class HttpLoadBalancer implements LoadBalancer {
478501
return targets[index]! // Guaranteed to exist due to length check
479502
}
480503

504+
private selectPowerOfTwoChoices(
505+
targets: LoadBalancerTarget[],
506+
): LoadBalancerTarget {
507+
// Sample two distinct targets at random and choose the better one by connections then latency
508+
const i = Math.floor(Math.random() * targets.length)
509+
let j = Math.floor(Math.random() * targets.length)
510+
if (j === i) j = (j + 1) % targets.length
511+
const a = targets[i]!
512+
const b = targets[j]!
513+
return this.betterByLoadThenLatency(a, b)
514+
}
515+
516+
private selectByLatency(targets: LoadBalancerTarget[]): LoadBalancerTarget {
517+
// Choose the target with the smallest averageResponseTime; fallback to round-robin if missing data
518+
let best = targets[0]!
519+
let bestLatency = best.averageResponseTime ?? Number.POSITIVE_INFINITY
520+
for (let k = 1; k < targets.length; k++) {
521+
const t = targets[k]!
522+
const lat = t.averageResponseTime ?? Number.POSITIVE_INFINITY
523+
if (lat < bestLatency) {
524+
best = t
525+
bestLatency = lat
526+
}
527+
}
528+
if (!isFinite(bestLatency)) {
529+
// No latency data available yet
530+
return this.selectRoundRobin(targets)
531+
}
532+
return best
533+
}
534+
535+
private selectWeightedLeastConnections(
536+
targets: LoadBalancerTarget[],
537+
): LoadBalancerTarget {
538+
// Choose by minimal (connections + 1) / weight
539+
return targets.reduce((best, curr) => {
540+
const bestScore =
541+
(Math.max(0, best.connections ?? 0) + 1) / Math.max(1, best.weight ?? 1)
542+
const currScore =
543+
(Math.max(0, curr.connections ?? 0) + 1) / Math.max(1, curr.weight ?? 1)
544+
if (currScore < bestScore) return curr
545+
if (currScore === bestScore) return this.betterByLatency(best, curr)
546+
return best
547+
})
548+
}
549+
550+
private betterByLatency(a: LoadBalancerTarget, b: LoadBalancerTarget) {
551+
const la = a.averageResponseTime ?? Number.POSITIVE_INFINITY
552+
const lb = b.averageResponseTime ?? Number.POSITIVE_INFINITY
553+
return la <= lb ? a : b
554+
}
555+
556+
private betterByLoadThenLatency(
557+
a: LoadBalancerTarget,
558+
b: LoadBalancerTarget,
559+
) {
560+
const ca = a.connections ?? 0
561+
const cb = b.connections ?? 0
562+
if (ca < cb) return a
563+
if (cb < ca) return b
564+
return this.betterByLatency(a, b)
565+
}
566+
481567
private recordRequest(url: string): void {
482568
this.totalRequests++
483569
const target = this.targets.get(url)
@@ -552,10 +638,21 @@ export class HttpLoadBalancer implements LoadBalancer {
552638
}
553639

554640
private getClientId(request: Request): string {
555-
// In real scenario, would extract actual client IP
556-
// For now, use a combination of headers as identifier
557-
const userAgent = request.headers.get('user-agent') ?? ''
558-
const accept = request.headers.get('accept') ?? ''
641+
// Prefer real client IP headers commonly set by proxies/CDNs; fallback to UA+Accept
642+
const headers = request.headers
643+
const xff = headers.get('x-forwarded-for') || headers.get('X-Forwarded-For')
644+
if (xff) {
645+
const ip = xff.split(',')[0]!.trim()
646+
if (ip) return ip
647+
}
648+
const realIp =
649+
headers.get('x-real-ip') ||
650+
headers.get('cf-connecting-ip') ||
651+
headers.get('x-client-ip') ||
652+
''
653+
if (realIp) return realIp
654+
const userAgent = headers.get('user-agent') ?? ''
655+
const accept = headers.get('accept') ?? ''
559656
return userAgent + accept
560657
}
561658

@@ -596,7 +693,7 @@ export class HttpLoadBalancer implements LoadBalancer {
596693

597694
const response = await fetch(url.toString(), {
598695
signal: controller.signal,
599-
method: 'GET',
696+
method: healthCheckConfig.method ?? 'GET',
600697
})
601698

602699
clearTimeout(timeoutId)

test/cluster/fixtures/worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
process.on('SIGTERM', () => process.exit(0))
44

55
// Keep alive for a very long time (about 1 billion ms ~ 11.5 days)
6-
const KEEP_ALIVE_INTERVAL = 1 << 30;
6+
const KEEP_ALIVE_INTERVAL = 1 << 30
77
setInterval(() => {}, KEEP_ALIVE_INTERVAL)

test/e2e/random-loadbalancer.test.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,17 @@ describe('Random Load Balancer E2E Tests', () => {
271271
// Each server should get roughly 1/3 of requests (allow generous variance for randomness)
272272
const expectedPerServer = requestCount / 3
273273
expect(serverCounts['echo-1'] || 0).toBeGreaterThan(expectedPerServer * 0.3) // At least 30% of expected
274-
expect(serverCounts['echo-1'] || 0).toBeLessThan(expectedPerServer * 1.7) // At most 170% of expected
274+
expect(serverCounts['echo-1'] || 0).toBeLessThanOrEqual(
275+
expectedPerServer * 1.7,
276+
) // At most 170% of expected
275277
expect(serverCounts['echo-2'] || 0).toBeGreaterThan(expectedPerServer * 0.3)
276-
expect(serverCounts['echo-2'] || 0).toBeLessThan(expectedPerServer * 1.7)
278+
expect(serverCounts['echo-2'] || 0).toBeLessThanOrEqual(
279+
expectedPerServer * 1.7,
280+
)
277281
expect(serverCounts['echo-3'] || 0).toBeGreaterThan(expectedPerServer * 0.3)
278-
expect(serverCounts['echo-3'] || 0).toBeLessThan(expectedPerServer * 1.7)
282+
expect(serverCounts['echo-3'] || 0).toBeLessThanOrEqual(
283+
expectedPerServer * 1.7,
284+
)
279285
})
280286

281287
test('should randomly distribute requests between two servers', async () => {
@@ -309,9 +315,13 @@ describe('Random Load Balancer E2E Tests', () => {
309315
// Each server should get roughly half of requests (allow variance for randomness)
310316
const expectedPerServer = requestCount / 2
311317
expect(serverCounts['echo-1'] || 0).toBeGreaterThan(expectedPerServer * 0.3) // At least 30% of expected
312-
expect(serverCounts['echo-1'] || 0).toBeLessThan(expectedPerServer * 1.7) // At most 170% of expected
318+
expect(serverCounts['echo-1'] || 0).toBeLessThanOrEqual(
319+
expectedPerServer * 1.7,
320+
) // At most 170% of expected
313321
expect(serverCounts['echo-2'] || 0).toBeGreaterThan(expectedPerServer * 0.3)
314-
expect(serverCounts['echo-2'] || 0).toBeLessThan(expectedPerServer * 1.7)
322+
expect(serverCounts['echo-2'] || 0).toBeLessThanOrEqual(
323+
expectedPerServer * 1.7,
324+
)
315325
})
316326

317327
test('should show randomness across multiple test runs', async () => {

0 commit comments

Comments
 (0)