Skip to content

Commit e4ccd66

Browse files
committed
perf(grpc-js): cache precomputed serviceUrl in InternalChannel (#3097)
Cache the precomputed serviceUrl used by call credentials in a bounded two-level cache on InternalChannel instead of re-parsing the method path, splitting the host, and constructing a new template string on every RPC. - Add zero-allocation `extractServiceName` and `computeServiceUrl` helpers in `uri-parser.ts`. - Introduce a bounded two-level cache (`serviceUrlCache: Map<string, Map<string, string>>`) on `InternalChannel`, capped at 5 hosts and 100 methods per host to strictly bound memory while achieving zero-allocation lookups on cache hits. - Clear `serviceUrlCache` when the channel is closed. - Update `LoadBalancingCall` and `SingleSubchannelChannel` to use the shared URL computation and caching.
1 parent 17c61a5 commit e4ccd66

6 files changed

Lines changed: 253 additions & 27 deletions

File tree

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
import { trace, isTracerEnabled } from './logging';
3535
import { SubchannelAddress } from './subchannel-address';
3636
import { mapProxyName } from './http_proxy';
37-
import { GrpcUri, parseUri, uriToString } from './uri-parser';
37+
import { GrpcUri, computeServiceUrl, parseUri, uriToString } from './uri-parser';
3838
import { ServerSurfaceCall } from './server-call';
3939

4040
import { ConnectivityState } from './connectivity-state';
@@ -240,6 +240,21 @@ export class InternalChannel {
240240
Math.random() * Number.MAX_SAFE_INTEGER
241241
);
242242

243+
/**
244+
* Maximum number of distinct hosts to cache service URLs for on this channel.
245+
*/
246+
private static readonly MAX_CACHED_HOSTS = 5;
247+
/**
248+
* Maximum number of distinct method paths to cache per host.
249+
*/
250+
private static readonly MAX_METHODS_PER_HOST = 100;
251+
/**
252+
* Two-level cache mapping host to method name to precomputed service URL
253+
* (`https://${hostname}/${serviceName}`) used by call credentials. Nested
254+
* maps avoid intermediate composite key string allocations on the hot path.
255+
*/
256+
private readonly serviceUrlCache = new Map<string, Map<string, string>>();
257+
243258
constructor(
244259
target: string,
245260
private readonly credentials: ChannelCredentials,
@@ -694,6 +709,33 @@ export class InternalChannel {
694709
this.maybeStartIdleTimer();
695710
}
696711

712+
/**
713+
* Returns the precomputed service URL (`https://${hostname}/${serviceName}`)
714+
* for the given host and method, using a bounded cache to avoid parsing on
715+
* every RPC.
716+
*/
717+
getServiceUrl(host: string, methodName: string): string {
718+
let methodMap = this.serviceUrlCache.get(host);
719+
if (methodMap === undefined) {
720+
if (this.serviceUrlCache.size < InternalChannel.MAX_CACHED_HOSTS) {
721+
methodMap = new Map<string, string>();
722+
this.serviceUrlCache.set(host, methodMap);
723+
}
724+
}
725+
if (methodMap !== undefined) {
726+
const serviceUrl = methodMap.get(methodName);
727+
if (serviceUrl !== undefined) {
728+
return serviceUrl;
729+
}
730+
const computedServiceUrl = computeServiceUrl(host, methodName);
731+
if (methodMap.size < InternalChannel.MAX_METHODS_PER_HOST) {
732+
methodMap.set(methodName, computedServiceUrl);
733+
}
734+
return computedServiceUrl;
735+
}
736+
return computeServiceUrl(host, methodName);
737+
}
738+
697739
createLoadBalancingCall(
698740
callConfig: CallConfig,
699741
method: string,
@@ -819,6 +861,7 @@ export class InternalChannel {
819861
this.subchannelPool.unrefUnusedSubchannels();
820862
this.configSelector?.unref();
821863
this.configSelector = null;
864+
this.serviceUrlCache.clear();
822865
}
823866

824867
getTarget() {

‎packages/grpc-js/src/load-balancing-call.ts‎

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import { InternalChannel } from './internal-channel';
3131
import { Metadata } from './metadata';
3232
import { OnCallEnded, PickResultType } from './picker';
3333
import { CallConfig } from './resolver';
34-
import { splitHostPort } from './uri-parser';
3534
import * as logging from './logging';
3635
import { restrictControlPlaneStatusCode } from './control-plane-status';
3736
import * as http2 from 'http2';
@@ -73,18 +72,7 @@ export class LoadBalancingCall implements Call, DeadlineInfoProvider {
7372
private readonly deadline: Deadline,
7473
private readonly callNumber: number
7574
) {
76-
const splitPath: string[] = this.methodName.split('/');
77-
let serviceName = '';
78-
/* The standard path format is "/{serviceName}/{methodName}", so if we split
79-
* by '/', the first item should be empty and the second should be the
80-
* service name */
81-
if (splitPath.length >= 2) {
82-
serviceName = splitPath[1];
83-
}
84-
const hostname = splitHostPort(this.host)?.host ?? 'localhost';
85-
/* Currently, call credentials are only allowed on HTTPS connections, so we
86-
* can assume that the scheme is "https" */
87-
this.serviceUrl = `https://${hostname}/${serviceName}`;
75+
this.serviceUrl = this.channel.getServiceUrl(this.host, this.methodName);
8876
this.startTime = Date.now();
8977
}
9078
getDeadlineInfo(): string[] {

‎packages/grpc-js/src/single-subchannel-channel.ts‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { Metadata } from "./metadata";
3232
import { getDefaultAuthority } from "./resolver";
3333
import { Subchannel } from "./subchannel";
3434
import { SubchannelCall } from "./subchannel-call";
35-
import { GrpcUri, splitHostPort, uriToString } from "./uri-parser";
35+
import { GrpcUri, computeServiceUrl, uriToString } from "./uri-parser";
3636

3737
class SubchannelCallWrapper implements Call {
3838
private childCall: SubchannelCall | null = null;
@@ -46,18 +46,7 @@ class SubchannelCallWrapper implements Call {
4646
private readFilterPending = false;
4747
private writeFilterPending = false;
4848
constructor(private subchannel: Subchannel, private method: string, filterStackFactory: FilterStackFactory, private options: CallStreamOptions, private callNumber: number) {
49-
const splitPath: string[] = this.method.split('/');
50-
let serviceName = '';
51-
/* The standard path format is "/{serviceName}/{methodName}", so if we split
52-
* by '/', the first item should be empty and the second should be the
53-
* service name */
54-
if (splitPath.length >= 2) {
55-
serviceName = splitPath[1];
56-
}
57-
const hostname = splitHostPort(this.options.host)?.host ?? 'localhost';
58-
/* Currently, call credentials are only allowed on HTTPS connections, so we
59-
* can assume that the scheme is "https" */
60-
this.serviceUrl = `https://${hostname}/${serviceName}`;
49+
this.serviceUrl = computeServiceUrl(this.options.host, this.method);
6150
const timeout = getRelativeTimeout(options.deadline);
6251
if (timeout !== Infinity) {
6352
if (timeout <= 0) {

‎packages/grpc-js/src/uri-parser.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,29 @@ export function uriToString(uri: GrpcUri): string {
125125
result += uri.path;
126126
return result;
127127
}
128+
129+
/**
130+
* Extracts the service name from a gRPC method path (e.g.
131+
* "/package.Service/Method" -> "package.Service").
132+
*/
133+
export function extractServiceName(methodName: string): string {
134+
const firstSlashIndex = methodName.indexOf('/');
135+
if (firstSlashIndex === -1) {
136+
return '';
137+
}
138+
const secondSlashIndex = methodName.indexOf('/', firstSlashIndex + 1);
139+
if (secondSlashIndex === -1) {
140+
return methodName.substring(firstSlashIndex + 1);
141+
}
142+
return methodName.substring(firstSlashIndex + 1, secondSlashIndex);
143+
}
144+
145+
/**
146+
* Computes the service URL used by call credentials (e.g.
147+
* "https://hostname/serviceName").
148+
*/
149+
export function computeServiceUrl(host: string, methodName: string): string {
150+
const serviceName = extractServiceName(methodName);
151+
const hostname = splitHostPort(host)?.host ?? 'localhost';
152+
return `https://${hostname}/${serviceName}`;
153+
}

‎packages/grpc-js/test/test-client.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import * as http2 from 'http2';
2121

2222
import * as grpc from '../src';
2323
import { Client, Metadata, Server, ServerCredentials } from '../src';
24+
import { InternalChannel } from '../src/internal-channel';
2425
import { ConnectivityState } from '../src/connectivity-state';
2526
import { Http2SubchannelCall } from '../src/subchannel-call';
2627

@@ -453,3 +454,118 @@ describe('Client with a nonexistent target domain', () => {
453454
client.close();
454455
});
455456
});
457+
458+
describe('InternalChannel serviceUrl cache', () => {
459+
it('returns and caches serviceUrl for a given host and method', () => {
460+
const channel = new InternalChannel(
461+
'localhost:1234',
462+
grpc.credentials.createInsecure(),
463+
{}
464+
);
465+
const firstServiceUrl = channel.getServiceUrl(
466+
'spanner.googleapis.com:443',
467+
'/google.spanner.v1.Spanner/ExecuteSql'
468+
);
469+
assert.strictEqual(
470+
firstServiceUrl,
471+
'https://spanner.googleapis.com/google.spanner.v1.Spanner'
472+
);
473+
const cache = (
474+
channel as unknown as {
475+
serviceUrlCache: Map<string, Map<string, string>>;
476+
}
477+
).serviceUrlCache;
478+
const cachedServiceUrl = cache
479+
.get('spanner.googleapis.com:443')
480+
?.get('/google.spanner.v1.Spanner/ExecuteSql');
481+
assert.strictEqual(firstServiceUrl, cachedServiceUrl);
482+
483+
const secondServiceUrl = channel.getServiceUrl(
484+
'spanner.googleapis.com:443',
485+
'/google.spanner.v1.Spanner/ExecuteSql'
486+
);
487+
assert.strictEqual(secondServiceUrl, cachedServiceUrl);
488+
channel.close();
489+
});
490+
491+
it('bounds the number of cached hosts', () => {
492+
const channel = new InternalChannel(
493+
'localhost:1234',
494+
grpc.credentials.createInsecure(),
495+
{}
496+
);
497+
for (let hostIndex = 0; hostIndex < 10; hostIndex++) {
498+
const serviceUrl = channel.getServiceUrl(
499+
`host${hostIndex}.example.com:443`,
500+
'/service/method'
501+
);
502+
assert.strictEqual(
503+
serviceUrl,
504+
`https://host${hostIndex}.example.com/service`
505+
);
506+
}
507+
const cache = (
508+
channel as unknown as {
509+
serviceUrlCache: Map<string, Map<string, string>>;
510+
}
511+
).serviceUrlCache;
512+
assert.strictEqual(cache.size, 5);
513+
assert.strictEqual(cache.has('host0.example.com:443'), true);
514+
assert.strictEqual(cache.has('host4.example.com:443'), true);
515+
assert.strictEqual(cache.has('host5.example.com:443'), false);
516+
assert.strictEqual(cache.has('host9.example.com:443'), false);
517+
518+
// Calling an uncached host still computes the correct URL
519+
const uncachedHostServiceUrl = channel.getServiceUrl(
520+
'host5.example.com:443',
521+
'/service/method'
522+
);
523+
assert.strictEqual(
524+
uncachedHostServiceUrl,
525+
'https://host5.example.com/service'
526+
);
527+
assert.strictEqual(cache.size, 5);
528+
channel.close();
529+
assert.strictEqual(cache.size, 0);
530+
});
531+
532+
it('bounds the number of cached methods per host', () => {
533+
const channel = new InternalChannel(
534+
'localhost:1234',
535+
grpc.credentials.createInsecure(),
536+
{}
537+
);
538+
for (let methodIndex = 0; methodIndex < 150; methodIndex++) {
539+
const serviceUrl = channel.getServiceUrl(
540+
'localhost:50051',
541+
`/service${methodIndex}/method`
542+
);
543+
assert.strictEqual(serviceUrl, `https://localhost/service${methodIndex}`);
544+
}
545+
const cache = (
546+
channel as unknown as {
547+
serviceUrlCache: Map<string, Map<string, string>>;
548+
}
549+
).serviceUrlCache;
550+
const hostMethodMap = cache.get('localhost:50051');
551+
assert(hostMethodMap !== undefined);
552+
assert.strictEqual(hostMethodMap.size, 100);
553+
assert.strictEqual(hostMethodMap.has('/service0/method'), true);
554+
assert.strictEqual(hostMethodMap.has('/service99/method'), true);
555+
assert.strictEqual(hostMethodMap.has('/service100/method'), false);
556+
557+
// Calling an uncached method still computes the correct URL
558+
const uncachedMethodServiceUrl = channel.getServiceUrl(
559+
'localhost:50051',
560+
'/service100/method'
561+
);
562+
assert.strictEqual(
563+
uncachedMethodServiceUrl,
564+
'https://localhost/service100'
565+
);
566+
assert.strictEqual(hostMethodMap.size, 100);
567+
568+
channel.close();
569+
assert.strictEqual(cache.size, 0);
570+
});
571+
});

‎packages/grpc-js/test/test-uri-parser.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,68 @@ describe('URI Parser', function () {
144144
});
145145
}
146146
});
147+
148+
describe('extractServiceName', function () {
149+
const expectationList: { methodName: string; expected: string }[] = [
150+
{
151+
methodName: '/google.spanner.v1.Spanner/ExecuteSql',
152+
expected: 'google.spanner.v1.Spanner',
153+
},
154+
{ methodName: '/EchoService/Echo', expected: 'EchoService' },
155+
{ methodName: '/serviceOnly', expected: 'serviceOnly' },
156+
{ methodName: '/a/b/c', expected: 'a' },
157+
{ methodName: 'noLeading/second/third', expected: 'second' },
158+
{ methodName: 'noSlash', expected: '' },
159+
{ methodName: '', expected: '' },
160+
{ methodName: '/', expected: '' },
161+
{ methodName: '//', expected: '' },
162+
];
163+
for (const { methodName, expected } of expectationList) {
164+
it(methodName, function () {
165+
assert.strictEqual(uriParser.extractServiceName(methodName), expected);
166+
});
167+
}
168+
});
169+
170+
describe('computeServiceUrl', function () {
171+
const expectationList: {
172+
host: string;
173+
methodName: string;
174+
expected: string;
175+
}[] = [
176+
{
177+
host: 'spanner.googleapis.com:443',
178+
methodName: '/google.spanner.v1.Spanner/ExecuteSql',
179+
expected: 'https://spanner.googleapis.com/google.spanner.v1.Spanner',
180+
},
181+
{
182+
host: 'localhost:50051',
183+
methodName: '/EchoService/Echo',
184+
expected: 'https://localhost/EchoService',
185+
},
186+
{
187+
host: '[::1]:50051',
188+
methodName: '/EchoService/Echo',
189+
expected: 'https://::1/EchoService',
190+
},
191+
{
192+
host: '127.0.0.1:8080',
193+
methodName: '/EchoService/Echo',
194+
expected: 'https://127.0.0.1/EchoService',
195+
},
196+
{
197+
host: '[',
198+
methodName: '/EchoService/Echo',
199+
expected: 'https://localhost/EchoService',
200+
},
201+
];
202+
for (const { host, methodName, expected } of expectationList) {
203+
it(`${host} + ${methodName}`, function () {
204+
assert.strictEqual(
205+
uriParser.computeServiceUrl(host, methodName),
206+
expected
207+
);
208+
});
209+
}
210+
});
147211
});

0 commit comments

Comments
 (0)