Skip to content

Commit 47ac924

Browse files
authored
Merge pull request #1794 from murgatroid99/grpc-js-xds_circuit_breaking
grpc-js-xds: Add circuit breaking functionality
2 parents 4cf4f52 + 43a3bad commit 47ac924

File tree

5 files changed

+120
-16
lines changed

5 files changed

+120
-16
lines changed

packages/grpc-js-xds/interop/xds-interop-client.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ const currentConfig: ClientConfiguration = {
196196
let anyCallSucceeded = false;
197197

198198
const accumulatedStats: LoadBalancerAccumulatedStatsResponse = {
199+
num_rpcs_started_by_method: {
200+
EMPTY_CALL: 0,
201+
UNARY_CALL: 0
202+
},
203+
num_rpcs_succeeded_by_method: {
204+
EMPTY_CALL: 0,
205+
UNARY_CALL: 0
206+
},
207+
num_rpcs_failed_by_method: {
208+
EMPTY_CALL: 0,
209+
UNARY_CALL: 0
210+
},
199211
stats_per_method: {
200212
EMPTY_CALL: {
201213
rpcs_started: 0,
@@ -208,14 +220,28 @@ const accumulatedStats: LoadBalancerAccumulatedStatsResponse = {
208220
}
209221
};
210222

223+
function addAccumulatedCallStarted(callName: string) {
224+
accumulatedStats.stats_per_method![callName].rpcs_started! += 1;
225+
accumulatedStats.num_rpcs_started_by_method![callName] += 1;
226+
}
227+
228+
function addAccumulatedCallEnded(callName: string, result: grpc.status) {
229+
accumulatedStats.stats_per_method![callName].result![result] = (accumulatedStats.stats_per_method![callName].result![result] ?? 0) + 1;
230+
if (result === grpc.status.OK) {
231+
accumulatedStats.num_rpcs_succeeded_by_method![callName] += 1;
232+
} else {
233+
accumulatedStats.num_rpcs_failed_by_method![callName] += 1;
234+
}
235+
}
236+
211237
const callTimeHistogram: {[callType: string]: {[status: number]: number[]}} = {
212238
UnaryCall: {},
213239
EmptyCall: {}
214240
}
215241

216242
function makeSingleRequest(client: TestServiceClient, type: CallType, failOnFailedRpcs: boolean, callStatsTracker: CallStatsTracker) {
217-
const callTypeStats = accumulatedStats.stats_per_method![callTypeEnumMapReverse[type]];
218-
callTypeStats.rpcs_started! += 1;
243+
const callEnumName = callTypeEnumMapReverse[type];
244+
addAccumulatedCallStarted(callEnumName);
219245
const notifier = callStatsTracker.startCall();
220246
let gotMetadata: boolean = false;
221247
let hostname: string | null = null;
@@ -235,7 +261,7 @@ function makeSingleRequest(client: TestServiceClient, type: CallType, failOnFail
235261
} else {
236262
callTimeHistogram[type][statusCode][duration[0]] = 1;
237263
}
238-
callTypeStats.result![statusCode] = (callTypeStats.result![statusCode] ?? 0) + 1;
264+
addAccumulatedCallEnded(callEnumName, statusCode);
239265
if (error) {
240266
if (failOnFailedRpcs && anyCallSucceeded) {
241267
console.error('A call failed after a call succeeded');

packages/grpc-js-xds/scripts/xds.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ GRPC_NODE_TRACE=xds_client,xds_resolver,cds_balancer,eds_balancer,priority,weigh
5252
GRPC_NODE_VERBOSITY=DEBUG \
5353
NODE_XDS_INTEROP_VERBOSITY=1 \
5454
python3 grpc/tools/run_tests/run_xds_tests.py \
55-
--test_case="all,timeout" \
55+
--test_case="all,timeout,circuit_breaking" \
5656
--project_id=grpc-testing \
5757
--source_image=projects/grpc-testing/global/images/xds-test-server-4 \
5858
--path_to_server_binary=/java_server/grpc-java/interop-testing/build/install/grpc-interop-testing/bin/xds-test-server \

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,17 @@ export class CdsLoadBalancer implements LoadBalancer {
8080
this.watcher = {
8181
onValidUpdate: (update) => {
8282
this.latestCdsUpdate = update;
83+
let maxConcurrentRequests: number | undefined = undefined;
84+
for (const threshold of update.circuit_breakers?.thresholds ?? []) {
85+
if (threshold.priority === 'DEFAULT') {
86+
maxConcurrentRequests = threshold.max_requests?.value;
87+
}
88+
}
8389
/* the lrs_server.self field indicates that the same server should be
8490
* used for load reporting as for other xDS operations. Setting
8591
* lrsLoadReportingServerName to the empty string sets that behavior.
8692
* Otherwise, if the field is omitted, load reporting is disabled. */
87-
const edsConfig: EdsLoadBalancingConfig = new EdsLoadBalancingConfig(update.name, [], [], update.eds_cluster_config!.service_name === '' ? undefined : update.eds_cluster_config!.service_name, update.lrs_server?.self ? '' : undefined);
93+
const edsConfig: EdsLoadBalancingConfig = new EdsLoadBalancingConfig(update.name, [], [], update.eds_cluster_config!.service_name === '' ? undefined : update.eds_cluster_config!.service_name, update.lrs_server?.self ? '' : undefined, maxConcurrentRequests);
8894
trace('Child update EDS config: ' + JSON.stringify(edsConfig));
8995
this.childBalancer.updateAddressList(
9096
[],

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

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*
1616
*/
1717

18-
import { connectivityState as ConnectivityState, status as Status, Metadata, logVerbosity as LogVerbosity, experimental } from '@grpc/grpc-js';
18+
import { connectivityState as ConnectivityState, status as Status, Metadata, logVerbosity as LogVerbosity, experimental, StatusObject } from '@grpc/grpc-js';
1919
import { getSingletonXdsClient, XdsClient, XdsClusterDropStats } from './xds-client';
2020
import { ClusterLoadAssignment__Output } from './generated/envoy/config/endpoint/v3/ClusterLoadAssignment';
2121
import { Locality__Output } from './generated/envoy/api/v2/core/Locality';
@@ -34,6 +34,11 @@ import { validateLoadBalancingConfig } from '@grpc/grpc-js/build/src/experimenta
3434
import { WeightedTarget, WeightedTargetLoadBalancingConfig } from './load-balancer-weighted-target';
3535
import { LrsLoadBalancingConfig } from './load-balancer-lrs';
3636
import { Watcher } from './xds-stream-state/xds-stream-state';
37+
import Filter = experimental.Filter;
38+
import BaseFilter = experimental.BaseFilter;
39+
import FilterFactory = experimental.FilterFactory;
40+
import FilterStackFactory = experimental.FilterStackFactory;
41+
import CallStream = experimental.CallStream;
3742

3843
const TRACER_NAME = 'eds_balancer';
3944

@@ -47,15 +52,19 @@ function localityToName(locality: Locality__Output) {
4752
return `{region=${locality.region},zone=${locality.zone},sub_zone=${locality.sub_zone}}`;
4853
}
4954

55+
const DEFAULT_MAX_CONCURRENT_REQUESTS = 1024;
56+
5057
export class EdsLoadBalancingConfig implements LoadBalancingConfig {
58+
private maxConcurrentRequests: number;
5159
getLoadBalancerName(): string {
5260
return TYPE_NAME;
5361
}
5462
toJsonObject(): object {
5563
const jsonObj: {[key: string]: any} = {
5664
cluster: this.cluster,
5765
locality_picking_policy: this.localityPickingPolicy.map(policy => policy.toJsonObject()),
58-
endpoint_picking_policy: this.endpointPickingPolicy.map(policy => policy.toJsonObject())
66+
endpoint_picking_policy: this.endpointPickingPolicy.map(policy => policy.toJsonObject()),
67+
max_concurrent_requests: this.maxConcurrentRequests
5968
};
6069
if (this.edsServiceName !== undefined) {
6170
jsonObj.eds_service_name = this.edsServiceName;
@@ -68,8 +77,8 @@ export class EdsLoadBalancingConfig implements LoadBalancingConfig {
6877
};
6978
}
7079

71-
constructor(private cluster: string, private localityPickingPolicy: LoadBalancingConfig[], private endpointPickingPolicy: LoadBalancingConfig[], private edsServiceName?: string, private lrsLoadReportingServerName?: string) {
72-
80+
constructor(private cluster: string, private localityPickingPolicy: LoadBalancingConfig[], private endpointPickingPolicy: LoadBalancingConfig[], private edsServiceName?: string, private lrsLoadReportingServerName?: string, maxConcurrentRequests?: number) {
81+
this.maxConcurrentRequests = maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS;
7382
}
7483

7584
getCluster() {
@@ -92,6 +101,10 @@ export class EdsLoadBalancingConfig implements LoadBalancingConfig {
92101
return this.lrsLoadReportingServerName;
93102
}
94103

104+
getMaxConcurrentRequests() {
105+
return this.maxConcurrentRequests;
106+
}
107+
95108
static createFromJson(obj: any): EdsLoadBalancingConfig {
96109
if (!('cluster' in obj && typeof obj.cluster === 'string')) {
97110
throw new Error('eds config must have a string field cluster');
@@ -108,7 +121,28 @@ export class EdsLoadBalancingConfig implements LoadBalancingConfig {
108121
if ('lrs_load_reporting_server_name' in obj && (!obj.lrs_load_reporting_server_name === undefined || typeof obj.lrs_load_reporting_server_name === 'string')) {
109122
throw new Error('eds config lrs_load_reporting_server_name must be a string if provided');
110123
}
111-
return new EdsLoadBalancingConfig(obj.cluster, obj.locality_picking_policy.map(validateLoadBalancingConfig), obj.endpoint_picking_policy.map(validateLoadBalancingConfig), obj.eds_service_name, obj.lrs_load_reporting_server_name);
124+
if ('max_concurrent_requests' in obj && (!obj.max_concurrent_requests === undefined || typeof obj.max_concurrent_requests === 'number')) {
125+
throw new Error('eds config max_concurrent_requests must be a number if provided');
126+
}
127+
return new EdsLoadBalancingConfig(obj.cluster, obj.locality_picking_policy.map(validateLoadBalancingConfig), obj.endpoint_picking_policy.map(validateLoadBalancingConfig), obj.eds_service_name, obj.lrs_load_reporting_server_name, obj.max_concurrent_requests);
128+
}
129+
}
130+
131+
class CallEndTrackingFilter extends BaseFilter implements Filter {
132+
constructor(private onCallEnd: () => void) {
133+
super();
134+
}
135+
receiveTrailers(status: StatusObject) {
136+
this.onCallEnd();
137+
return status;
138+
}
139+
}
140+
141+
class CallTrackingFilterFactory implements FilterFactory<CallEndTrackingFilter> {
142+
constructor(private onCallEnd: () => void) {}
143+
144+
createFilter(callStream: CallStream) {
145+
return new CallEndTrackingFilter(this.onCallEnd);
112146
}
113147
}
114148

@@ -149,6 +183,8 @@ export class EdsLoadBalancer implements LoadBalancer {
149183

150184
private clusterDropStats: XdsClusterDropStats | null = null;
151185

186+
private concurrentRequests: number = 0;
187+
152188
constructor(private readonly channelControlHelper: ChannelControlHelper) {
153189
this.childBalancer = new ChildLoadBalancerHandler({
154190
createSubchannel: (subchannelAddress, subchannelArgs) =>
@@ -169,19 +205,42 @@ export class EdsLoadBalancer implements LoadBalancer {
169205
* Otherwise, delegate picking the subchannel to the child
170206
* balancer. */
171207
if (dropCategory === null) {
172-
return originalPicker.pick(pickArgs);
208+
const originalPick = originalPicker.pick(pickArgs);
209+
let extraFilterFactory: FilterFactory<Filter> = new CallTrackingFilterFactory(() => {
210+
this.concurrentRequests -= 1;
211+
});
212+
if (originalPick.extraFilterFactory) {
213+
extraFilterFactory = new FilterStackFactory([originalPick.extraFilterFactory, extraFilterFactory]);
214+
}
215+
return {
216+
pickResultType: originalPick.pickResultType,
217+
status: originalPick.status,
218+
subchannel: originalPick.subchannel,
219+
onCallStarted: () => {
220+
originalPick.onCallStarted?.();
221+
this.concurrentRequests += 1;
222+
},
223+
extraFilterFactory: extraFilterFactory
224+
};
173225
} else {
174-
this.clusterDropStats?.addCallDropped(dropCategory);
226+
let details: string;
227+
if (dropCategory === true) {
228+
details = 'Call dropped by load balancing policy.';
229+
this.clusterDropStats?.addUncategorizedCallDropped();
230+
} else {
231+
details = `Call dropped by load balancing policy. Category: ${dropCategory}`;
232+
this.clusterDropStats?.addCallDropped(dropCategory);
233+
}
175234
return {
176235
pickResultType: PickResultType.DROP,
177236
status: {
178237
code: Status.UNAVAILABLE,
179-
details: `Call dropped by load balancing policy. Category: ${dropCategory}`,
238+
details: details,
180239
metadata: new Metadata(),
181240
},
182241
subchannel: null,
183242
extraFilterFactory: null,
184-
onCallStarted: null,
243+
onCallStarted: null
185244
};
186245
}
187246
},
@@ -218,9 +277,13 @@ export class EdsLoadBalancer implements LoadBalancer {
218277
/**
219278
* Check whether a single call should be dropped according to the current
220279
* policy, based on randomly chosen numbers. Returns the drop category if
221-
* the call should be dropped, and null otherwise.
280+
* the call should be dropped, and null otherwise. true is a valid
281+
* output, as a sentinel value indicating a drop with no category.
222282
*/
223-
private checkForDrop(): string | null {
283+
private checkForDrop(): string | true | null {
284+
if (this.lastestConfig && this.concurrentRequests >= this.lastestConfig.getMaxConcurrentRequests()) {
285+
return true;
286+
}
224287
if (!this.latestEdsUpdate?.policy) {
225288
return null;
226289
}

packages/grpc-js-xds/src/xds-client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ function localityEqual(
114114
}
115115

116116
export interface XdsClusterDropStats {
117+
addUncategorizedCallDropped(): void;
117118
addCallDropped(category: string): void;
118119
}
119120

@@ -158,6 +159,7 @@ interface ClusterLocalityStats {
158159

159160
interface ClusterLoadReport {
160161
callsDropped: Map<string, number>;
162+
uncategorizedCallsDropped: number;
161163
localityStats: ClusterLocalityStats[];
162164
intervalStart: [number, number];
163165
}
@@ -195,6 +197,7 @@ class ClusterLoadReportMap {
195197
}
196198
const newStats: ClusterLoadReport = {
197199
callsDropped: new Map<string, number>(),
200+
uncategorizedCallsDropped: 0,
198201
localityStats: [],
199202
intervalStart: process.hrtime(),
200203
};
@@ -871,8 +874,10 @@ export class XdsClient {
871874
totalDroppedRequests += count;
872875
}
873876
}
877+
totalDroppedRequests += stats.uncategorizedCallsDropped;
874878
// Clear out dropped call stats after sending them
875879
stats.callsDropped.clear();
880+
stats.uncategorizedCallsDropped = 0;
876881
const interval = process.hrtime(stats.intervalStart);
877882
stats.intervalStart = process.hrtime();
878883
// Skip clusters with 0 requests
@@ -957,6 +962,7 @@ export class XdsClient {
957962
trace('addClusterDropStats(lrsServer=' + lrsServer + ', clusterName=' + clusterName + ', edsServiceName=' + edsServiceName + ')');
958963
if (lrsServer !== '') {
959964
return {
965+
addUncategorizedCallDropped: () => {},
960966
addCallDropped: (category) => {},
961967
};
962968
}
@@ -965,6 +971,9 @@ export class XdsClient {
965971
edsServiceName
966972
);
967973
return {
974+
addUncategorizedCallDropped: () => {
975+
clusterStats.uncategorizedCallsDropped += 1;
976+
},
968977
addCallDropped: (category) => {
969978
const prevCount = clusterStats.callsDropped.get(category) ?? 0;
970979
clusterStats.callsDropped.set(category, prevCount + 1);

0 commit comments

Comments
 (0)