Skip to content

Commit 51c5b94

Browse files
authored
Merge pull request #2471 from murgatroid99/grpc-js_channel_idle_timeout
grpc-js: Implement channel idle timeout
2 parents d507624 + 89cd8f7 commit 51c5b94

File tree

9 files changed

+347
-29
lines changed

9 files changed

+347
-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: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ 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 {
24+
UnavailablePicker,
25+
Picker,
26+
PickResultType,
27+
QueuePicker,
28+
} from './picker';
2429
import { Metadata } from './metadata';
2530
import { Status, LogVerbosity, Propagate } from './constants';
2631
import { FilterStackFactory } from './filter-stack';
@@ -85,6 +90,11 @@ import {
8590
*/
8691
const MAX_TIMEOUT_TIME = 2147483647;
8792

93+
const MIN_IDLE_TIMEOUT_MS = 1000;
94+
95+
// 30 minutes
96+
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
97+
8898
interface ConnectivityStateWatcher {
8999
currentState: ConnectivityState;
90100
timer: NodeJS.Timeout | null;
@@ -153,8 +163,8 @@ class ChannelSubchannelWrapper
153163
}
154164

155165
export class InternalChannel {
156-
private resolvingLoadBalancer: ResolvingLoadBalancer;
157-
private subchannelPool: SubchannelPool;
166+
private readonly resolvingLoadBalancer: ResolvingLoadBalancer;
167+
private readonly subchannelPool: SubchannelPool;
158168
private connectivityState: ConnectivityState = ConnectivityState.IDLE;
159169
private currentPicker: Picker = new UnavailablePicker();
160170
/**
@@ -164,17 +174,17 @@ export class InternalChannel {
164174
private configSelectionQueue: ResolvingCall[] = [];
165175
private pickQueue: LoadBalancingCall[] = [];
166176
private connectivityStateWatchers: ConnectivityStateWatcher[] = [];
167-
private defaultAuthority: string;
168-
private filterStackFactory: FilterStackFactory;
169-
private target: GrpcUri;
177+
private readonly defaultAuthority: string;
178+
private readonly filterStackFactory: FilterStackFactory;
179+
private readonly target: GrpcUri;
170180
/**
171181
* This timer does not do anything on its own. Its purpose is to hold the
172182
* event loop open while there are any pending calls for the channel that
173183
* have not yet been assigned to specific subchannels. In other words,
174184
* the invariant is that callRefTimer is reffed if and only if pickQueue
175185
* is non-empty.
176186
*/
177-
private callRefTimer: NodeJS.Timer;
187+
private readonly callRefTimer: NodeJS.Timer;
178188
private configSelector: ConfigSelector | null = null;
179189
/**
180190
* This is the error from the name resolver if it failed most recently. It
@@ -184,17 +194,22 @@ export class InternalChannel {
184194
* than TRANSIENT_FAILURE.
185195
*/
186196
private currentResolutionError: StatusObject | null = null;
187-
private retryBufferTracker: MessageBufferTracker;
197+
private readonly retryBufferTracker: MessageBufferTracker;
188198
private keepaliveTime: number;
189-
private wrappedSubchannels: Set<ChannelSubchannelWrapper> = new Set();
199+
private readonly wrappedSubchannels: Set<ChannelSubchannelWrapper> =
200+
new Set();
201+
202+
private callCount = 0;
203+
private idleTimer: NodeJS.Timer | null = null;
204+
private readonly idleTimeoutMs: number;
190205

191206
// Channelz info
192207
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();
208+
private readonly originalTarget: string;
209+
private readonly channelzRef: ChannelRef;
210+
private readonly channelzTrace: ChannelzTrace;
211+
private readonly callTracker = new ChannelzCallTracker();
212+
private readonly childrenTracker = new ChannelzChildrenTracker();
198213

199214
constructor(
200215
target: string,
@@ -265,6 +280,10 @@ export class InternalChannel {
265280
DEFAULT_PER_RPC_RETRY_BUFFER_SIZE_BYTES
266281
);
267282
this.keepaliveTime = options['grpc.keepalive_time_ms'] ?? -1;
283+
this.idleTimeoutMs = Math.max(
284+
options['grpc.client_idle_timeout_ms'] ?? DEFAULT_IDLE_TIMEOUT_MS,
285+
MIN_IDLE_TIMEOUT_MS
286+
);
268287
const channelControlHelper: ChannelControlHelper = {
269288
createSubchannel: (
270289
subchannelAddress: SubchannelAddress,
@@ -548,6 +567,49 @@ export class InternalChannel {
548567
this.callRefTimerRef();
549568
}
550569

570+
private enterIdle() {
571+
this.resolvingLoadBalancer.destroy();
572+
this.updateState(ConnectivityState.IDLE);
573+
this.currentPicker = new QueuePicker(this.resolvingLoadBalancer);
574+
}
575+
576+
private maybeStartIdleTimer() {
577+
if (this.callCount === 0) {
578+
this.idleTimer = setTimeout(() => {
579+
this.trace(
580+
'Idle timer triggered after ' +
581+
this.idleTimeoutMs +
582+
'ms of inactivity'
583+
);
584+
this.enterIdle();
585+
}, this.idleTimeoutMs);
586+
this.idleTimer.unref?.();
587+
}
588+
}
589+
590+
private onCallStart() {
591+
if (this.channelzEnabled) {
592+
this.callTracker.addCallStarted();
593+
}
594+
this.callCount += 1;
595+
if (this.idleTimer) {
596+
clearTimeout(this.idleTimer);
597+
this.idleTimer = null;
598+
}
599+
}
600+
601+
private onCallEnd(status: StatusObject) {
602+
if (this.channelzEnabled) {
603+
if (status.code === Status.OK) {
604+
this.callTracker.addCallSucceeded();
605+
} else {
606+
this.callTracker.addCallFailed();
607+
}
608+
}
609+
this.callCount -= 1;
610+
this.maybeStartIdleTimer();
611+
}
612+
551613
createLoadBalancingCall(
552614
callConfig: CallConfig,
553615
method: string,
@@ -653,16 +715,10 @@ export class InternalChannel {
653715
callNumber
654716
);
655717

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-
}
718+
this.onCallStart();
719+
call.addStatusWatcher(status => {
720+
this.onCallEnd(status);
721+
});
666722
return call;
667723
}
668724

@@ -685,6 +741,7 @@ export class InternalChannel {
685741
const connectivityState = this.connectivityState;
686742
if (tryToConnect) {
687743
this.resolvingLoadBalancer.exitIdle();
744+
this.maybeStartIdleTimer();
688745
}
689746
return connectivityState;
690747
}

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() {

0 commit comments

Comments
 (0)