Skip to content

Commit c36b3db

Browse files
authored
Merge pull request #1449 from murgatroid99/grpc-js_weighted_target_lb
grpc-js: Add weighted_target load balancer
2 parents ab5910a + 668b5ae commit c36b3db

File tree

3 files changed

+346
-1
lines changed

3 files changed

+346
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
/*
2+
* Copyright 2020 gRPC authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
import { LoadBalancer, ChannelControlHelper, getFirstUsableConfig, registerLoadBalancerType } from "./load-balancer";
19+
import { SubchannelAddress } from "./subchannel";
20+
import { LoadBalancingConfig, WeightedTarget, isWeightedTargetLoadBalancingConfig } from "./load-balancing-config";
21+
import { Picker, PickResult, PickArgs, QueuePicker, UnavailablePicker } from "./picker";
22+
import { ConnectivityState } from "./channel";
23+
import { ChildLoadBalancerHandler } from "./load-balancer-child-handler";
24+
import { Status } from "./constants";
25+
import { Metadata } from "./metadata";
26+
import { isLocalitySubchannelAddress, LocalitySubchannelAddress } from "./load-balancer-priority";
27+
28+
const TYPE_NAME = 'weighted_target';
29+
30+
const DEFAULT_RETENTION_INTERVAL_MS = 15 * 60 * 1000;
31+
32+
/**
33+
* Represents a picker and a subinterval of a larger interval used for randomly
34+
* selecting an element of a list of these objects.
35+
*/
36+
interface WeightedPicker {
37+
picker: Picker;
38+
/**
39+
* The exclusive end of the interval associated with this element. The start
40+
* of the interval is implicitly the rangeEnd of the previous element in the
41+
* list, or 0 for the first element in the list.
42+
*/
43+
rangeEnd: number;
44+
}
45+
46+
class WeightedTargetPicker implements Picker {
47+
private rangeTotal: number;
48+
constructor(private readonly pickerList: WeightedPicker[]) {
49+
this.rangeTotal = pickerList[pickerList.length - 1].rangeEnd;
50+
}
51+
pick(pickArgs: PickArgs): PickResult {
52+
// num | 0 is equivalent to floor(num)
53+
const selection = (Math.random() * this.rangeTotal) | 0;
54+
55+
/* Binary search for the element of the list such that
56+
* pickerList[index - 1].rangeEnd <= selection < pickerList[index].rangeEnd
57+
*/
58+
let mid = 0;
59+
let startIndex = 0;
60+
let endIndex = this.pickerList.length - 1;
61+
let index = 0;
62+
while (endIndex > startIndex) {
63+
mid = ((startIndex + endIndex) / 2) | 0;
64+
if (this.pickerList[mid].rangeEnd > selection) {
65+
endIndex = mid;
66+
} else if (this.pickerList[mid].rangeEnd < selection) {
67+
startIndex = mid + 1;
68+
} else {
69+
// + 1 here because the range is exclusive at the top end
70+
index = mid + 1;
71+
break;
72+
}
73+
}
74+
if (index === 0) {
75+
index = startIndex;
76+
}
77+
78+
return this.pickerList[index].picker.pick(pickArgs);
79+
}
80+
}
81+
82+
interface WeightedChild {
83+
updateAddressList(addressList: SubchannelAddress[], lbConfig: WeightedTarget, attributes: { [key: string]: unknown; }): void;
84+
exitIdle(): void;
85+
resetBackoff(): void;
86+
destroy(): void;
87+
deactivate(): void;
88+
maybeReactivate(): void;
89+
getConnectivityState(): ConnectivityState;
90+
getPicker(): Picker;
91+
getWeight(): number;
92+
}
93+
94+
export class WeightedTargetLoadBalancer implements LoadBalancer {
95+
private WeightedChildImpl = class implements WeightedChild {
96+
private connectivityState: ConnectivityState = ConnectivityState.IDLE;
97+
private picker: Picker;
98+
private childBalancer: ChildLoadBalancerHandler;
99+
private deactivationTimer: NodeJS.Timer | null = null;
100+
private weight: number = 0;
101+
102+
constructor(private parent: WeightedTargetLoadBalancer, private name: string) {
103+
this.childBalancer = new ChildLoadBalancerHandler({
104+
createSubchannel: (subchannelAddress, subchannelOptions) => {
105+
return this.parent.channelControlHelper.createSubchannel(subchannelAddress, subchannelOptions);
106+
},
107+
updateState: (connectivityState, picker) => {
108+
this.updateState(connectivityState, picker);
109+
},
110+
requestReresolution: () => {
111+
this.parent.channelControlHelper.requestReresolution();
112+
}
113+
});
114+
115+
this.picker = new QueuePicker(this.childBalancer);
116+
}
117+
118+
private updateState(connectivityState: ConnectivityState, picker: Picker) {
119+
this.connectivityState = connectivityState;
120+
this.picker = picker;
121+
this.parent.updateState();
122+
}
123+
124+
updateAddressList(addressList: SubchannelAddress[], lbConfig: WeightedTarget, attributes: { [key: string]: unknown; }): void {
125+
this.weight = lbConfig.weight;
126+
const childConfig = getFirstUsableConfig(lbConfig.child_policy);
127+
if (childConfig !== null) {
128+
this.childBalancer.updateAddressList(addressList, childConfig, attributes);
129+
}
130+
}
131+
exitIdle(): void {
132+
this.childBalancer.exitIdle();
133+
}
134+
resetBackoff(): void {
135+
this.childBalancer.resetBackoff();
136+
}
137+
destroy(): void {
138+
this.childBalancer.destroy();
139+
if (this.deactivationTimer !== null) {
140+
clearTimeout(this.deactivationTimer);
141+
}
142+
}
143+
deactivate(): void {
144+
if (this.deactivationTimer === null) {
145+
this.deactivationTimer = setTimeout(() => {
146+
this.parent.targets.delete(this.name);
147+
this.deactivationTimer = null;
148+
}, DEFAULT_RETENTION_INTERVAL_MS);
149+
}
150+
}
151+
maybeReactivate(): void {
152+
if (this.deactivationTimer !== null) {
153+
clearTimeout(this.deactivationTimer);
154+
this.deactivationTimer = null;
155+
}
156+
}
157+
getConnectivityState(): ConnectivityState {
158+
return this.connectivityState;
159+
}
160+
getPicker(): Picker {
161+
return this.picker;
162+
}
163+
getWeight(): number {
164+
return this.weight;
165+
}
166+
}
167+
// end of WeightedChildImpl
168+
169+
/**
170+
* Map of target names to target children. Includes current targets and
171+
* previous targets with deactivation timers that have not yet triggered.
172+
*/
173+
private targets: Map<string, WeightedChild> = new Map<string, WeightedChild>();
174+
/**
175+
* List of current target names.
176+
*/
177+
private targetList: string[] = [];
178+
179+
constructor(private channelControlHelper: ChannelControlHelper) {}
180+
181+
private updateState() {
182+
const pickerList: WeightedPicker[] = [];
183+
let end = 0;
184+
185+
let connectingCount = 0;
186+
let idleCount = 0;
187+
let transientFailureCount = 0;
188+
for (const targetName of this.targetList) {
189+
const target = this.targets.get(targetName);
190+
if (target === undefined) {
191+
continue;
192+
}
193+
switch (target.getConnectivityState()) {
194+
case ConnectivityState.READY:
195+
end += target.getWeight();
196+
pickerList.push({
197+
picker: target.getPicker(),
198+
rangeEnd: end
199+
});
200+
break;
201+
case ConnectivityState.CONNECTING:
202+
connectingCount += 1;
203+
break;
204+
case ConnectivityState.IDLE:
205+
idleCount += 1;
206+
break;
207+
case ConnectivityState.TRANSIENT_FAILURE:
208+
transientFailureCount += 1;
209+
break;
210+
default:
211+
// Ignore the other possiblity, SHUTDOWN
212+
}
213+
}
214+
215+
let connectivityState: ConnectivityState;
216+
if (pickerList.length > 0) {
217+
connectivityState = ConnectivityState.READY;
218+
} else if (connectingCount > 0) {
219+
connectivityState = ConnectivityState.CONNECTING;
220+
} else if (idleCount > 0) {
221+
connectivityState = ConnectivityState.IDLE;
222+
} else {
223+
connectivityState = ConnectivityState.TRANSIENT_FAILURE;
224+
}
225+
226+
let picker: Picker;
227+
switch (connectivityState) {
228+
case ConnectivityState.READY:
229+
picker = new WeightedTargetPicker(pickerList);
230+
break;
231+
case ConnectivityState.CONNECTING:
232+
case ConnectivityState.READY:
233+
picker = new QueuePicker(this);
234+
break;
235+
default:
236+
picker = new UnavailablePicker({
237+
code: Status.UNAVAILABLE,
238+
details: 'weighted_target: all children report state TRANSIENT_FAILURE',
239+
metadata: new Metadata()
240+
});
241+
}
242+
this.channelControlHelper.updateState(connectivityState, picker);
243+
}
244+
245+
updateAddressList(addressList: SubchannelAddress[], lbConfig: LoadBalancingConfig, attributes: { [key: string]: unknown; }): void {
246+
if (!isWeightedTargetLoadBalancingConfig(lbConfig)) {
247+
// Reject a config of the wrong type
248+
return;
249+
}
250+
251+
/* For each address, the first element of its localityPath array determines
252+
* which child it belongs to. So we bucket those addresses by that first
253+
* element, and pass along the rest of the localityPath for that child
254+
* to use. */
255+
const childAddressMap = new Map<string, SubchannelAddress[]>();
256+
for (const address of addressList) {
257+
if (!isLocalitySubchannelAddress(address)) {
258+
// Reject address that cannot be associated with targets
259+
return;
260+
}
261+
if (address.localityPath.length < 1) {
262+
// Reject address that cannot be associated with targets
263+
return;
264+
}
265+
const childName = address.localityPath[0];
266+
const childAddress: LocalitySubchannelAddress = {
267+
...address,
268+
localityPath: address.localityPath.slice(1),
269+
};
270+
let childAddressList = childAddressMap.get(childName);
271+
if (childAddressList === undefined) {
272+
childAddressList = [];
273+
childAddressMap.set(childName, childAddressList);
274+
}
275+
childAddressList.push(childAddress);
276+
}
277+
278+
this.targetList = Array.from(lbConfig.weighted_target.targets.keys());
279+
for (const [targetName, targetConfig] of lbConfig.weighted_target.targets) {
280+
let target = this.targets.get(targetName);
281+
if (target === undefined) {
282+
target = new this.WeightedChildImpl(this, targetName);
283+
this.targets.set(targetName, target);
284+
} else {
285+
target.maybeReactivate();
286+
}
287+
target.updateAddressList(childAddressMap.get(targetName) ?? [], targetConfig, attributes);
288+
}
289+
290+
// Deactivate targets that are not in the new config
291+
for (const [targetName, target] of this.targets) {
292+
if (this.targetList.indexOf(targetName) < 0) {
293+
target.deactivate();
294+
}
295+
}
296+
297+
this.updateState();
298+
}
299+
exitIdle(): void {
300+
for (const targetName of this.targetList) {
301+
this.targets.get(targetName)?.exitIdle();
302+
}
303+
}
304+
resetBackoff(): void {
305+
for (const targetName of this.targetList) {
306+
this.targets.get(targetName)?.resetBackoff();
307+
}
308+
}
309+
destroy(): void {
310+
for (const target of this.targets.values()) {
311+
target.destroy();
312+
}
313+
this.targets.clear();
314+
}
315+
getTypeName(): string {
316+
return TYPE_NAME;
317+
}
318+
}
319+
320+
export function setup() {
321+
registerLoadBalancerType(TYPE_NAME, WeightedTargetLoadBalancer);
322+
}

packages/grpc-js/src/load-balancer.ts

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { LoadBalancingConfig } from './load-balancing-config';
2323
import * as load_balancer_pick_first from './load-balancer-pick-first';
2424
import * as load_balancer_round_robin from './load-balancer-round-robin';
2525
import * as load_balancer_priority from './load-balancer-priority';
26+
import * as load_balancer_weighted_target from './load-balancer-weighted-target';
2627

2728
/**
2829
* A collection of functions associated with a channel that a load balancer
@@ -139,4 +140,5 @@ export function registerAll() {
139140
load_balancer_pick_first.setup();
140141
load_balancer_round_robin.setup();
141142
load_balancer_priority.setup();
143+
load_balancer_weighted_target.setup();
142144
}

packages/grpc-js/src/load-balancing-config.ts

+22-1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export interface PriorityLbConfig {
4848
priorities: string[];
4949
}
5050

51+
export interface WeightedTarget {
52+
weight: number;
53+
child_policy: LoadBalancingConfig[];
54+
}
55+
56+
export interface WeightedTargetLbConfig {
57+
targets: Map<string, WeightedTarget>;
58+
}
59+
5160
export interface PickFirstLoadBalancingConfig {
5261
name: 'pick_first';
5362
pick_first: PickFirstConfig;
@@ -73,12 +82,18 @@ export interface PriorityLoadBalancingConfig {
7382
priority: PriorityLbConfig;
7483
}
7584

85+
export interface WeightedTargetLoadBalancingConfig {
86+
name: 'weighted_target';
87+
weighted_target: WeightedTargetLbConfig;
88+
}
89+
7690
export type LoadBalancingConfig =
7791
| PickFirstLoadBalancingConfig
7892
| RoundRobinLoadBalancingConfig
7993
| XdsLoadBalancingConfig
8094
| GrpcLbLoadBalancingConfig
81-
| PriorityLoadBalancingConfig;
95+
| PriorityLoadBalancingConfig
96+
| WeightedTargetLoadBalancingConfig;
8297

8398
export function isRoundRobinLoadBalancingConfig(
8499
lbconfig: LoadBalancingConfig
@@ -104,6 +119,12 @@ export function isPriorityLoadBalancingConfig(
104119
return lbconfig.name === 'priority';
105120
}
106121

122+
export function isWeightedTargetLoadBalancingConfig(
123+
lbconfig: LoadBalancingConfig
124+
): lbconfig is WeightedTargetLoadBalancingConfig {
125+
return lbconfig.name === 'weighted_target';
126+
}
127+
107128
/* In these functions we assume the input came from a JSON object. Therefore we
108129
* expect that the prototype is uninteresting and that `in` can be used
109130
* effectively */

0 commit comments

Comments
 (0)