From 25668ce03ee78bc71105aa1a6739f27e3cde12d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Thu, 24 Sep 2026 14:12:21 +0200 Subject: [PATCH] perf(grpc-js): memoize method config lookup in default config selector On every RPC, the default config selector parsed the method name by splitting the string, filtering empty segments, and looping through method config match levels to find the appropriate configuration. In high-throughput clients, this performed repetitive string splitting, array allocations, and lookups on the hot path. This change: 1. Caches the resolved MethodConfig by method name in a Map within getDefaultConfigSelector, avoiding repeated string splitting and search loops on cache hits. 2. Bounds the cache to 100 method entries to keep memory usage fixed. 3. Clears the cache in unref() when channel resolution updates or the channel closes. 4. Returns fresh CallConfig containers for each RPC, preserving call isolation for pickInformation and dynamic filters. --- .../grpc-js/src/resolving-load-balancer.ts | 71 +++--- .../test/test-resolving-load-balancer.ts | 241 ++++++++++++++++++ 2 files changed, 281 insertions(+), 31 deletions(-) create mode 100644 packages/grpc-js/test/test-resolving-load-balancer.ts diff --git a/packages/grpc-js/src/resolving-load-balancer.ts b/packages/grpc-js/src/resolving-load-balancer.ts index c117e9455..adb45a2fe 100644 --- a/packages/grpc-js/src/resolving-load-balancer.ts +++ b/packages/grpc-js/src/resolving-load-balancer.ts @@ -27,7 +27,7 @@ import { validateServiceConfig, } from './service-config'; import { ConnectivityState } from './connectivity-state'; -import { CHANNEL_ARGS_CONFIG_SELECTOR_KEY, ConfigSelector, createResolver, Resolver } from './resolver'; +import { CallConfig, CHANNEL_ARGS_CONFIG_SELECTOR_KEY, ConfigSelector, createResolver, Resolver } from './resolver'; import { Picker, UnavailablePicker, QueuePicker } from './picker'; import { BackoffOptions, BackoffTimeout } from './backoff-timeout'; import { Status } from './constants'; @@ -99,49 +99,58 @@ function findMatchingConfig( return null; } -function getDefaultConfigSelector( +const MAX_CACHED_METHOD_CONFIGS = 100; + +export function getDefaultConfigSelector( serviceConfig: ServiceConfig | null ): ConfigSelector { + const methodConfigCache = new Map(); return { - invoke( + invoke( methodName: string, - metadata: Metadata - ) { - const splitName = methodName.split('/').filter(x => x.length > 0); - const service = splitName[0] ?? ''; - const method = splitName[1] ?? ''; - if (serviceConfig && serviceConfig.methodConfig) { - /* Check for the following in order, and return the first method - * config that matches: - * 1. A name that exactly matches the service and method - * 2. A name with no method set that matches the service - * 3. An empty name - */ - for (const matchLevel of NAME_MATCH_LEVEL_ORDER) { - const matchingConfig = findMatchingConfig( - service, - method, - serviceConfig.methodConfig, - matchLevel - ); - if (matchingConfig) { - return { - methodConfig: matchingConfig, - pickInformation: {}, - status: Status.OK, - dynamicFilterFactories: [], - }; + metadata: Metadata, + channelId: number + ): CallConfig { + let matchingConfig = methodConfigCache.get(methodName); + if (matchingConfig === undefined) { + const splitName = methodName.split('/').filter(x => x.length > 0); + const service = splitName[0] ?? ''; + const method = splitName[1] ?? ''; + matchingConfig = { name: [] }; + if (serviceConfig && serviceConfig.methodConfig) { + /* Check for the following in order, and return the first method + * config that matches: + * 1. A name that exactly matches the service and method + * 2. A name with no method set that matches the service + * 3. An empty name + */ + for (const matchLevel of NAME_MATCH_LEVEL_ORDER) { + const foundConfig = findMatchingConfig( + service, + method, + serviceConfig.methodConfig, + matchLevel + ); + if (foundConfig) { + matchingConfig = foundConfig; + break; + } } } + if (methodConfigCache.size < MAX_CACHED_METHOD_CONFIGS) { + methodConfigCache.set(methodName, matchingConfig); + } } return { - methodConfig: { name: [] }, + methodConfig: matchingConfig, pickInformation: {}, status: Status.OK, dynamicFilterFactories: [], }; }, - unref() {} + unref() { + methodConfigCache.clear(); + } }; } diff --git a/packages/grpc-js/test/test-resolving-load-balancer.ts b/packages/grpc-js/test/test-resolving-load-balancer.ts new file mode 100644 index 000000000..2aa0660a0 --- /dev/null +++ b/packages/grpc-js/test/test-resolving-load-balancer.ts @@ -0,0 +1,241 @@ +/* + * Copyright 2026 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import * as assert from 'assert'; +import { getDefaultConfigSelector } from '../src/resolving-load-balancer'; +import { ServiceConfig } from '../src/service-config'; +import { Metadata } from '../src/metadata'; +import { Status } from '../src/constants'; + +describe('getDefaultConfigSelector', () => { + const dummyMetadata = new Metadata(); + const dummyChannelId = 0; + + const sampleServiceConfigWithoutWildcard: ServiceConfig = { + loadBalancingConfig: [], + methodConfig: [ + { + name: [{ service: 'TestService', method: 'SpecificMethod' }], + timeout: { seconds: 10, nanos: 0 }, + }, + { + name: [{ service: 'TestService' }], + timeout: { seconds: 20, nanos: 0 }, + }, + ], + }; + + const sampleServiceConfigWithWildcard: ServiceConfig = { + loadBalancingConfig: [], + methodConfig: [ + { + name: [{ service: 'TestService', method: 'SpecificMethod' }], + timeout: { seconds: 10, nanos: 0 }, + }, + { + name: [{ service: 'TestService' }], + timeout: { seconds: 20, nanos: 0 }, + }, + { + name: [{}], + timeout: { seconds: 30, nanos: 0 }, + }, + ], + }; + + it('matches exact service and method', () => { + const selector = getDefaultConfigSelector( + sampleServiceConfigWithoutWildcard + ); + const callConfig = selector.invoke( + '/TestService/SpecificMethod', + dummyMetadata, + dummyChannelId + ); + assert.strictEqual(callConfig.status, Status.OK); + assert.strictEqual(callConfig.methodConfig.timeout?.seconds, 10); + }); + + it('matches service-level config when method does not match', () => { + const selector = getDefaultConfigSelector( + sampleServiceConfigWithoutWildcard + ); + const callConfig = selector.invoke( + '/TestService/OtherMethod', + dummyMetadata, + dummyChannelId + ); + assert.strictEqual(callConfig.status, Status.OK); + assert.strictEqual(callConfig.methodConfig.timeout?.seconds, 20); + }); + + it('returns empty name default config when serviceConfig is present but neither method nor service matches', () => { + const selector = getDefaultConfigSelector( + sampleServiceConfigWithoutWildcard + ); + const callConfig = selector.invoke( + '/OtherService/AnyMethod', + dummyMetadata, + dummyChannelId + ); + assert.strictEqual(callConfig.status, Status.OK); + assert.deepStrictEqual(callConfig.methodConfig.name, []); + assert.strictEqual(callConfig.methodConfig.timeout, undefined); + }); + + it('matches empty name default config when service does not match and wildcard is present', () => { + const selector = getDefaultConfigSelector(sampleServiceConfigWithWildcard); + const callConfig = selector.invoke( + '/OtherService/AnyMethod', + dummyMetadata, + dummyChannelId + ); + assert.strictEqual(callConfig.status, Status.OK); + assert.strictEqual(callConfig.methodConfig.timeout?.seconds, 30); + }); + + it('returns default config when serviceConfig is null', () => { + const selector = getDefaultConfigSelector(null); + const callConfig = selector.invoke( + '/TestService/TestMethod', + dummyMetadata, + dummyChannelId + ); + assert.strictEqual(callConfig.status, Status.OK); + assert.deepStrictEqual(callConfig.methodConfig.name, []); + assert.strictEqual(callConfig.methodConfig.timeout, undefined); + }); + + it('memoizes MethodConfig and returns isolated CallConfig containers', () => { + const selector = getDefaultConfigSelector( + sampleServiceConfigWithoutWildcard + ); + const firstCallConfig = selector.invoke( + '/TestService/SpecificMethod', + dummyMetadata, + dummyChannelId + ); + const secondCallConfig = selector.invoke( + '/TestService/SpecificMethod', + dummyMetadata, + dummyChannelId + ); + + // CallConfig containers must be isolated + assert.notStrictEqual(firstCallConfig, secondCallConfig); + assert.notStrictEqual( + firstCallConfig.pickInformation, + secondCallConfig.pickInformation + ); + assert.notStrictEqual( + firstCallConfig.dynamicFilterFactories, + secondCallConfig.dynamicFilterFactories + ); + + // Underlying resolved MethodConfig must be memoized by reference + assert.strictEqual( + firstCallConfig.methodConfig, + secondCallConfig.methodConfig + ); + + // Mutations on one call's pickInformation must not leak to the other + (firstCallConfig.pickInformation as Record)['testKey'] = + 'testValue'; + assert.strictEqual( + (secondCallConfig.pickInformation as Record)['testKey'], + undefined + ); + }); + + it('bounds the cache size to 100 entries', () => { + const selector = getDefaultConfigSelector( + sampleServiceConfigWithoutWildcard + ); + for (let methodIndex = 0; methodIndex < 150; methodIndex++) { + const callConfig = selector.invoke( + `/UnmatchedService/Method${methodIndex}`, + dummyMetadata, + dummyChannelId + ); + assert.strictEqual(callConfig.status, Status.OK); + } + + // Method0 was in the first 100 calls, so its resolved MethodConfig is cached by reference + const firstCachedCallConfig = selector.invoke( + '/UnmatchedService/Method0', + dummyMetadata, + dummyChannelId + ); + const secondCachedCallConfig = selector.invoke( + '/UnmatchedService/Method0', + dummyMetadata, + dummyChannelId + ); + assert.strictEqual( + firstCachedCallConfig.methodConfig, + secondCachedCallConfig.methodConfig + ); + + // Method149 was beyond 100, so it was not cached; each invoke generates a fresh { name: [] } + const firstUncachedCallConfig = selector.invoke( + '/UnmatchedService/Method149', + dummyMetadata, + dummyChannelId + ); + const secondUncachedCallConfig = selector.invoke( + '/UnmatchedService/Method149', + dummyMetadata, + dummyChannelId + ); + assert.strictEqual(firstUncachedCallConfig.status, Status.OK); + assert.deepStrictEqual( + firstUncachedCallConfig.methodConfig, + secondUncachedCallConfig.methodConfig + ); + assert.notStrictEqual( + firstUncachedCallConfig.methodConfig, + secondUncachedCallConfig.methodConfig + ); + }); + + it('clears the cache on unref()', () => { + const selector = getDefaultConfigSelector( + sampleServiceConfigWithoutWildcard + ); + const firstCallConfig = selector.invoke( + '/UnmatchedService/MethodX', + dummyMetadata, + dummyChannelId + ); + selector.unref(); + + // After unref, a subsequent invoke will recompute a fresh MethodConfig object + const secondCallConfig = selector.invoke( + '/UnmatchedService/MethodX', + dummyMetadata, + dummyChannelId + ); + assert.deepStrictEqual( + firstCallConfig.methodConfig, + secondCallConfig.methodConfig + ); + assert.notStrictEqual( + firstCallConfig.methodConfig, + secondCallConfig.methodConfig + ); + }); +});