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 + ); + }); +});