Skip to content

Commit fcff72b

Browse files
committed
grpc-js: Implement channel idle timeout
1 parent dbaaa89 commit fcff72b

File tree

9 files changed

+292
-29
lines changed

9 files changed

+292
-29
lines changed

packages/grpc-js/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Many channel arguments supported in `grpc` are not supported in `@grpc/grpc-js`.
6363
- `grpc.per_rpc_retry_buffer_size`
6464
- `grpc.retry_buffer_size`
6565
- `grpc.service_config_disable_resolution`
66+
- `grpc.client_idle_timeout_ms`
6667
- `grpc-node.max_session_memory`
6768
- `channelOverride`
6869
- `channelFactoryOverride`

packages/grpc-js/src/channel-options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface ChannelOptions {
5656
'grpc.max_connection_age_grace_ms'?: number;
5757
'grpc-node.max_session_memory'?: number;
5858
'grpc.service_config_disable_resolution'?: number;
59+
'grpc.client_idle_timeout_ms'?: number;
5960
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6061
[key: string]: any;
6162
}
@@ -89,6 +90,7 @@ export const recognizedOptions = {
8990
'grpc.max_connection_age_grace_ms': true,
9091
'grpc-node.max_session_memory': true,
9192
'grpc.service_config_disable_resolution': true,
93+
'grpc.client_idle_timeout_ms': true
9294
};
9395

9496
export function channelOptionsEqual(

packages/grpc-js/src/internal-channel.ts

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { ChannelOptions } from './channel-options';
2020
import { ResolvingLoadBalancer } from './resolving-load-balancer';
2121
import { SubchannelPool, getSubchannelPool } from './subchannel-pool';
2222
import { ChannelControlHelper } from './load-balancer';
23-
import { UnavailablePicker, Picker, PickResultType } from './picker';
23+
import { UnavailablePicker, Picker, PickResultType, QueuePicker } from './picker';
2424
import { Metadata } from './metadata';
2525
import { Status, LogVerbosity, Propagate } from './constants';
2626
import { FilterStackFactory } from './filter-stack';
@@ -85,6 +85,11 @@ import {
8585
*/
8686
const MAX_TIMEOUT_TIME = 2147483647;
8787

88+
const MIN_IDLE_TIMEOUT_MS = 1000;
89+
90+
// 30 minutes
91+
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
92+
8893
interface ConnectivityStateWatcher {
8994
currentState: ConnectivityState;
9095
timer: NodeJS.Timeout | null;
@@ -153,8 +158,8 @@ class ChannelSubchannelWrapper
153158
}
154159

155160
export class InternalChannel {
156-
private resolvingLoadBalancer: ResolvingLoadBalancer;
157-
private subchannelPool: SubchannelPool;
161+
private readonly resolvingLoadBalancer: ResolvingLoadBalancer;
162+
private readonly subchannelPool: SubchannelPool;
158163
private connectivityState: ConnectivityState = ConnectivityState.IDLE;
159164
private currentPicker: Picker = new UnavailablePicker();
160165
/**
@@ -164,17 +169,17 @@ export class InternalChannel {
164169
private configSelectionQueue: ResolvingCall[] = [];
165170
private pickQueue: LoadBalancingCall[] = [];
166171
private connectivityStateWatchers: ConnectivityStateWatcher[] = [];
167-
private defaultAuthority: string;
168-
private filterStackFactory: FilterStackFactory;
169-
private target: GrpcUri;
172+
private readonly defaultAuthority: string;
173+
private readonly filterStackFactory: FilterStackFactory;
174+
private readonly target: GrpcUri;
170175
/**
171176
* This timer does not do anything on its own. Its purpose is to hold the
172177
* event loop open while there are any pending calls for the channel that
173178
* have not yet been assigned to specific subchannels. In other words,
174179
* the invariant is that callRefTimer is reffed if and only if pickQueue
175180
* is non-empty.
176181
*/
177-
private callRefTimer: NodeJS.Timer;
182+
private readonly callRefTimer: NodeJS.Timer;
178183
private configSelector: ConfigSelector | null = null;
179184
/**
180185
* This is the error from the name resolver if it failed most recently. It
@@ -184,17 +189,21 @@ export class InternalChannel {
184189
* than TRANSIENT_FAILURE.
185190
*/
186191
private currentResolutionError: StatusObject | null = null;
187-
private retryBufferTracker: MessageBufferTracker;
192+
private readonly retryBufferTracker: MessageBufferTracker;
188193
private keepaliveTime: number;
189-
private wrappedSubchannels: Set<ChannelSubchannelWrapper> = new Set();
194+
private readonly wrappedSubchannels: Set<ChannelSubchannelWrapper> = new Set();
195+
196+
private callCount: number = 0;
197+
private idleTimer: NodeJS.Timer | null = null;
198+
private readonly idleTimeoutMs: number;
190199

191200
// Channelz info
192201
private readonly channelzEnabled: boolean = true;
193-
private originalTarget: string;
194-
private channelzRef: ChannelRef;
195-
private channelzTrace: ChannelzTrace;
196-
private callTracker = new ChannelzCallTracker();
197-
private childrenTracker = new ChannelzChildrenTracker();
202+
private readonly originalTarget: string;
203+
private readonly channelzRef: ChannelRef;
204+
private readonly channelzTrace: ChannelzTrace;
205+
private readonly callTracker = new ChannelzCallTracker();
206+
private readonly childrenTracker = new ChannelzChildrenTracker();
198207

199208
constructor(
200209
target: string,
@@ -265,6 +274,7 @@ export class InternalChannel {
265274
DEFAULT_PER_RPC_RETRY_BUFFER_SIZE_BYTES
266275
);
267276
this.keepaliveTime = options['grpc.keepalive_time_ms'] ?? -1;
277+
this.idleTimeoutMs = Math.max(options['grpc.client_idle_timeout_ms'] ?? DEFAULT_IDLE_TIMEOUT_MS, MIN_IDLE_TIMEOUT_MS);
268278
const channelControlHelper: ChannelControlHelper = {
269279
createSubchannel: (
270280
subchannelAddress: SubchannelAddress,
@@ -548,6 +558,45 @@ export class InternalChannel {
548558
this.callRefTimerRef();
549559
}
550560

561+
private enterIdle() {
562+
this.resolvingLoadBalancer.destroy();
563+
this.updateState(ConnectivityState.IDLE);
564+
this.currentPicker = new QueuePicker(this.resolvingLoadBalancer);
565+
}
566+
567+
private maybeStartIdleTimer() {
568+
if (this.callCount === 0) {
569+
this.idleTimer = setTimeout(() => {
570+
this.trace('Idle timer triggered after ' + this.idleTimeoutMs + 'ms of inactivity');
571+
this.enterIdle();
572+
}, this.idleTimeoutMs);
573+
this.idleTimer.unref?.();
574+
}
575+
}
576+
577+
private onCallStart() {
578+
if (this.channelzEnabled) {
579+
this.callTracker.addCallStarted();
580+
}
581+
this.callCount += 1;
582+
if (this.idleTimer) {
583+
clearTimeout(this.idleTimer);
584+
this.idleTimer = null;
585+
}
586+
}
587+
588+
private onCallEnd(status: StatusObject) {
589+
if (this.channelzEnabled) {
590+
if (status.code === Status.OK) {
591+
this.callTracker.addCallSucceeded();
592+
} else {
593+
this.callTracker.addCallFailed();
594+
}
595+
}
596+
this.callCount -= 1;
597+
this.maybeStartIdleTimer();
598+
}
599+
551600
createLoadBalancingCall(
552601
callConfig: CallConfig,
553602
method: string,
@@ -653,16 +702,10 @@ export class InternalChannel {
653702
callNumber
654703
);
655704

656-
if (this.channelzEnabled) {
657-
this.callTracker.addCallStarted();
658-
call.addStatusWatcher(status => {
659-
if (status.code === Status.OK) {
660-
this.callTracker.addCallSucceeded();
661-
} else {
662-
this.callTracker.addCallFailed();
663-
}
664-
});
665-
}
705+
this.onCallStart();
706+
call.addStatusWatcher(status => {
707+
this.onCallEnd(status);
708+
});
666709
return call;
667710
}
668711

@@ -685,6 +728,7 @@ export class InternalChannel {
685728
const connectivityState = this.connectivityState;
686729
if (tryToConnect) {
687730
this.resolvingLoadBalancer.exitIdle();
731+
this.maybeStartIdleTimer();
688732
}
689733
return connectivityState;
690734
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ export class ChildLoadBalancerHandler implements LoadBalancer {
150150
}
151151
}
152152
destroy(): void {
153+
/* Note: state updates are only propagated from the child balancer if that
154+
* object is equal to this.currentChild or this.pendingChild. Since this
155+
* function sets both of those to null, no further state updates will
156+
* occur after this function returns. */
153157
if (this.currentChild) {
154158
this.currentChild.destroy();
155159
this.currentChild = null;

packages/grpc-js/src/resolver-dns.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ class DnsResolver implements Resolver {
207207
this.pendingLookupPromise = dnsLookupPromise(hostname, { all: true });
208208
this.pendingLookupPromise.then(
209209
addressList => {
210+
if (this.pendingLookupPromise === null) {
211+
return;
212+
}
210213
this.pendingLookupPromise = null;
211214
this.backoff.reset();
212215
this.backoff.stop();
@@ -248,6 +251,9 @@ class DnsResolver implements Resolver {
248251
);
249252
},
250253
err => {
254+
if (this.pendingLookupPromise === null) {
255+
return;
256+
}
251257
trace(
252258
'Resolution error for target ' +
253259
uriToString(this.target) +
@@ -268,6 +274,9 @@ class DnsResolver implements Resolver {
268274
this.pendingTxtPromise = resolveTxtPromise(hostname);
269275
this.pendingTxtPromise.then(
270276
txtRecord => {
277+
if (this.pendingTxtPromise === null) {
278+
return;
279+
}
271280
this.pendingTxtPromise = null;
272281
try {
273282
this.latestServiceConfig = extractAndSelectServiceConfig(
@@ -348,10 +357,21 @@ class DnsResolver implements Resolver {
348357
}
349358
}
350359

360+
/**
361+
* Reset the resolver to the same state it had when it was created. In-flight
362+
* DNS requests cannot be cancelled, but they are discarded and their results
363+
* will be ignored.
364+
*/
351365
destroy() {
352366
this.continueResolving = false;
367+
this.backoff.reset();
353368
this.backoff.stop();
354369
this.stopNextResolutionTimer();
370+
this.pendingLookupPromise = null;
371+
this.pendingTxtPromise = null;
372+
this.latestLookupResult = null;
373+
this.latestServiceConfig = null;
374+
this.latestServiceConfigError = null;
355375
}
356376

357377
/**

packages/grpc-js/src/resolver.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ export interface Resolver {
8282
updateResolution(): void;
8383

8484
/**
85-
* Destroy the resolver. Should be called when the owning channel shuts down.
85+
* Discard all resources owned by the resolver. A later call to
86+
* `updateResolution` should reinitialize those resources. No
87+
* `ResolverListener` callbacks should be called after `destroy` is called
88+
* until `updateResolution` is called again.
8689
*/
8790
destroy(): void;
8891
}

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ export class ResolvingLoadBalancer implements LoadBalancer {
9494
/**
9595
* The resolver class constructed for the target address.
9696
*/
97-
private innerResolver: Resolver;
97+
private readonly innerResolver: Resolver;
9898

99-
private childLoadBalancer: ChildLoadBalancerHandler;
99+
private readonly childLoadBalancer: ChildLoadBalancerHandler;
100100
private latestChildState: ConnectivityState = ConnectivityState.IDLE;
101101
private latestChildPicker: Picker = new QueuePicker(this);
102102
/**
@@ -324,7 +324,13 @@ export class ResolvingLoadBalancer implements LoadBalancer {
324324
destroy() {
325325
this.childLoadBalancer.destroy();
326326
this.innerResolver.destroy();
327-
this.updateState(ConnectivityState.SHUTDOWN, new UnavailablePicker());
327+
this.backoffTimeout.reset();
328+
this.backoffTimeout.stop();
329+
this.latestChildState = ConnectivityState.IDLE;
330+
this.latestChildPicker = new QueuePicker(this);
331+
this.currentState = ConnectivityState.IDLE;
332+
this.previousServiceConfig = null;
333+
this.continueResolving = false;
328334
}
329335

330336
getTypeName() {

packages/grpc-js/test/common.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717

1818
import * as loader from '@grpc/proto-loader';
1919
import * as assert2 from './assert2';
20+
import * as path from 'path';
21+
import * as grpc from '../src';
2022

21-
import { GrpcObject, loadPackageDefinition } from '../src/make-client';
23+
import { GrpcObject, ServiceClientConstructor, ServiceClient, loadPackageDefinition } from '../src/make-client';
24+
import { readFileSync } from 'fs';
2225

2326
const protoLoaderOptions = {
2427
keepCase: true,
@@ -37,4 +40,89 @@ export function loadProtoFile(file: string): GrpcObject {
3740
return loadPackageDefinition(packageDefinition);
3841
}
3942

43+
const protoFile = path.join(__dirname, 'fixtures', 'echo_service.proto');
44+
const echoService = loadProtoFile(protoFile)
45+
.EchoService as ServiceClientConstructor;
46+
47+
const ca = readFileSync(path.join(__dirname, 'fixtures', 'ca.pem'));
48+
const key = readFileSync(path.join(__dirname, 'fixtures', 'server1.key'));
49+
const cert = readFileSync(path.join(__dirname, 'fixtures', 'server1.pem'));
50+
51+
const serviceImpl = {
52+
echo: (
53+
call: grpc.ServerUnaryCall<any, any>,
54+
callback: grpc.sendUnaryData<any>
55+
) => {
56+
callback(null, call.request);
57+
},
58+
};
59+
60+
export class TestServer {
61+
private server: grpc.Server;
62+
public port: number | null = null;
63+
constructor(public useTls: boolean, options?: grpc.ChannelOptions) {
64+
this.server = new grpc.Server(options);
65+
this.server.addService(echoService.service, serviceImpl);
66+
}
67+
start(): Promise<void> {
68+
let credentials: grpc.ServerCredentials;
69+
if (this.useTls) {
70+
credentials = grpc.ServerCredentials.createSsl(null, [{private_key: key, cert_chain: cert}]);
71+
} else {
72+
credentials = grpc.ServerCredentials.createInsecure();
73+
}
74+
return new Promise<void>((resolve, reject) => {
75+
this.server.bindAsync('localhost:0', credentials, (error, port) => {
76+
if (error) {
77+
reject(error);
78+
return;
79+
}
80+
this.port = port;
81+
this.server.start();
82+
resolve();
83+
});
84+
});
85+
}
86+
87+
shutdown() {
88+
this.server.forceShutdown();
89+
}
90+
}
91+
92+
export class TestClient {
93+
private client: ServiceClient;
94+
constructor(port: number, useTls: boolean, options?: grpc.ChannelOptions) {
95+
let credentials: grpc.ChannelCredentials;
96+
if (useTls) {
97+
credentials = grpc.credentials.createSsl(ca);
98+
} else {
99+
credentials = grpc.credentials.createInsecure();
100+
}
101+
this.client = new echoService(`localhost:${port}`, credentials, options);
102+
}
103+
104+
static createFromServer(server: TestServer, options?: grpc.ChannelOptions) {
105+
if (server.port === null) {
106+
throw new Error('Cannot create client, server not started');
107+
}
108+
return new TestClient(server.port, server.useTls, options);
109+
}
110+
111+
waitForReady(deadline: grpc.Deadline, callback: (error?: Error) => void) {
112+
this.client.waitForReady(deadline, callback);
113+
}
114+
115+
sendRequest(callback: (error: grpc.ServiceError) => void) {
116+
this.client.echo({}, callback);
117+
}
118+
119+
getChannelState() {
120+
return this.client.getChannel().getConnectivityState(false);
121+
}
122+
123+
close() {
124+
this.client.close();
125+
}
126+
}
127+
40128
export { assert2 };

0 commit comments

Comments
 (0)