From 20df824fba48c929c37698fe7450749d0688bc82 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 30 Apr 2026 11:41:50 +0530 Subject: [PATCH 1/7] feat(spanner): add experimental numChannels round-robin transport mode --- handwritten/spanner/src/index.ts | 73 +++++++++++++++++++++----- handwritten/spanner/src/transaction.ts | 14 +++++ 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/handwritten/spanner/src/index.ts b/handwritten/spanner/src/index.ts index e46f34d109a2..7c2720340525 100644 --- a/handwritten/spanner/src/index.ts +++ b/handwritten/spanner/src/index.ts @@ -166,6 +166,12 @@ export interface SpannerOptions extends GrpcClientOptions { >; observabilityOptions?: ObservabilityOptions; disableBuiltInMetrics?: boolean; + /** + * Experimental. Number of independent Spanner data clients/channels to use + * for transaction RPCs. When set, grpc-gcp channel pooling is disabled and + * read/write transactions are assigned to these clients round-robin. + */ + numChannels?: number; interceptors?: any[]; sessionLabels?: {[key: string]: string}; /** @@ -187,6 +193,7 @@ export interface RequestConfig { reqOpts: any; gaxOpts?: CallOptions; headers: {[k: string]: string}; + channelHint?: number; } export interface CreateInstanceRequest { config?: string; @@ -327,6 +334,8 @@ class Spanner extends GrpcService { private _isInSecureCredentials: boolean; private static _isAFEServerTimingEnabled: boolean | undefined; readonly _nthClientId: number; + private _numChannels: number; + private _nextChannelHint: number; /** * Placeholder used to auto populate a column with the commit timestamp. @@ -399,6 +408,10 @@ class Spanner extends GrpcService { } constructor(options?: SpannerOptions) { + const numChannels = + options?.numChannels && options.numChannels > 0 + ? Math.floor(options.numChannels) + : 0; const scopes: Array<{}> = []; const clientClasses = [ v1.DatabaseAdminClient, @@ -413,21 +426,33 @@ class Spanner extends GrpcService { } } - options = Object.assign( - { - libName: 'gccl', - libVersion: require('../../package.json').version, - scopes, - // Add grpc keep alive setting - 'grpc.keepalive_time_ms': 120000, + const defaultOptions = { + libName: 'gccl', + libVersion: require('../../package.json').version, + scopes, + // Add grpc keep alive setting + 'grpc.keepalive_time_ms': 120000, + grpc, + }; + if (!numChannels) { + Object.assign(defaultOptions, { // Enable grpc-gcp support 'grpc.callInvocationTransformer': grpcGcp.gcpCallInvocationTransformer, 'grpc.channelFactoryOverride': grpcGcp.gcpChannelFactoryOverride, 'grpc.gcpApiConfig': grpcGcp.createGcpApiConfig(gcpApiConfig), - grpc, - }, - options || {}, - ) as {} as SpannerOptions; + }); + } + options = Object.assign(defaultOptions, options || {}) as {} as SpannerOptions; + if (numChannels) { + Object.assign(defaultOptions, { + // Keep each generated gRPC channel on its own HTTP/2 transport. + 'grpc.use_local_subchannel_pool': 1, + }); + delete options['grpc.callInvocationTransformer']; + delete options['grpc.channelFactoryOverride']; + delete options['grpc.gcpApiConfig']; + delete options.numChannels; + } const directedReadOptions = options.directedReadOptions ? options.directedReadOptions @@ -505,6 +530,17 @@ class Spanner extends GrpcService { this._universeDomain = universeEndpoint; this.projectId_ = options.projectId; this.configureMetrics_(options.disableBuiltInMetrics); + this._numChannels = numChannels; + this._nextChannelHint = 0; + } + + _nextTransactionChannelHint(): number | undefined { + if (!this._numChannels) { + return undefined; + } + const channelHint = this._nextChannelHint % this._numChannels; + this._nextChannelHint++; + return channelHint; } get universeDomain() { @@ -1681,14 +1717,23 @@ class Spanner extends GrpcService { return; } const clientName = config.client; + let channelHint: number | undefined; + if (clientName === 'SpannerClient' && this._numChannels) { + channelHint = + typeof config.channelHint === 'number' + ? config.channelHint % this._numChannels + : this._nextTransactionChannelHint(); + } + const clientKey = + channelHint === undefined ? clientName : `${clientName}:${channelHint}`; try { - if (!this.clients_.has(clientName)) { - this.clients_.set(clientName, new v1[clientName](this.options)); + if (!this.clients_.has(clientKey)) { + this.clients_.set(clientKey, new v1[clientName](this.options)); } } catch (err) { callback(err, null); } - const gaxClient = this.clients_.get(clientName)!; + const gaxClient = this.clients_.get(clientKey)!; let reqOpts = extend(true, {}, config.reqOpts); reqOpts = replaceProjectIdToken(reqOpts, projectId!); // It would have been preferable to replace the projectId already in the diff --git a/handwritten/spanner/src/transaction.ts b/handwritten/spanner/src/transaction.ts index 75d4b2d00794..b25b35130b26 100644 --- a/handwritten/spanner/src/transaction.ts +++ b/handwritten/spanner/src/transaction.ts @@ -311,6 +311,7 @@ export class Snapshot extends EventEmitter { _traceConfig: traceConfig; protected _dbName?: string; protected _mutationKey: spannerClient.spanner.v1.Mutation | null; + protected _channelHint?: number; /** * The transaction ID. @@ -620,6 +621,7 @@ export class Snapshot extends EventEmitter { method: 'beginTransaction', reqOpts, gaxOpts, + channelHint: this._ensureChannelHint(), headers: injectRequestIDIntoHeaders(headers, this.session), }, ( @@ -919,6 +921,7 @@ export class Snapshot extends EventEmitter { method: 'streamingRead', reqOpts: Object.assign({}, reqOpts, {resumeToken}), gaxOpts: gaxOptions, + channelHint: this._ensureChannelHint(), headers: injectRequestIDIntoHeaders( headers, this.session, @@ -1538,6 +1541,7 @@ export class Snapshot extends EventEmitter { method: 'executeStreamingSql', reqOpts: Object.assign({}, reqOpts, {resumeToken}), gaxOpts: gaxOptions, + channelHint: this._ensureChannelHint(), headers: injectRequestIDIntoHeaders( headers, this.session, @@ -1853,6 +1857,13 @@ export class Snapshot extends EventEmitter { protected _getSpanner(): Spanner { return this.session.parent.parent.parent as Spanner; } + + protected _ensureChannelHint(): number | undefined { + if (this._channelHint === undefined) { + this._channelHint = this._getSpanner()._nextTransactionChannelHint(); + } + return this._channelHint; + } } /*! Developer Documentation @@ -2225,6 +2236,7 @@ export class Transaction extends Dml { method: 'executeBatchDml', reqOpts, gaxOpts, + channelHint: this._ensureChannelHint(), headers: headers, }, ( @@ -2462,6 +2474,7 @@ export class Transaction extends Dml { method: 'commit', reqOpts, gaxOpts: gaxOpts, + channelHint: this._ensureChannelHint(), headers: injectRequestIDIntoHeaders( headers, this.session, @@ -2825,6 +2838,7 @@ export class Transaction extends Dml { method: 'rollback', reqOpts, gaxOpts, + channelHint: this._ensureChannelHint(), headers: headers, }, (err: null | ServiceError) => { From a8781fe648786032c28efa156ddc993e05feff77 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 30 Apr 2026 12:38:49 +0530 Subject: [PATCH 2/7] incorporate suggestions --- handwritten/spanner/src/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/handwritten/spanner/src/index.ts b/handwritten/spanner/src/index.ts index 7c2720340525..28ef2f21fc1a 100644 --- a/handwritten/spanner/src/index.ts +++ b/handwritten/spanner/src/index.ts @@ -408,10 +408,14 @@ class Spanner extends GrpcService { } constructor(options?: SpannerOptions) { - const numChannels = + const hasCustomerGcpApiConfig = + options && options['grpc.gcpApiConfig'] !== undefined; + const requestedNumChannels = options?.numChannels && options.numChannels > 0 ? Math.floor(options.numChannels) : 0; + const numChannels = + requestedNumChannels || (hasCustomerGcpApiConfig ? 0 : 4); const scopes: Array<{}> = []; const clientClasses = [ v1.DatabaseAdminClient, @@ -538,8 +542,8 @@ class Spanner extends GrpcService { if (!this._numChannels) { return undefined; } - const channelHint = this._nextChannelHint % this._numChannels; - this._nextChannelHint++; + const channelHint = this._nextChannelHint; + this._nextChannelHint = (this._nextChannelHint + 1) % this._numChannels; return channelHint; } From 16ef557ebaf9a0884028541d4af20390cf01e736 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 30 Apr 2026 12:55:05 +0530 Subject: [PATCH 3/7] fix tests --- handwritten/spanner/src/transaction.ts | 6 ++++- handwritten/spanner/test/index.ts | 31 +++++++++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/handwritten/spanner/src/transaction.ts b/handwritten/spanner/src/transaction.ts index b25b35130b26..dd60041e5317 100644 --- a/handwritten/spanner/src/transaction.ts +++ b/handwritten/spanner/src/transaction.ts @@ -1860,7 +1860,11 @@ export class Snapshot extends EventEmitter { protected _ensureChannelHint(): number | undefined { if (this._channelHint === undefined) { - this._channelHint = this._getSpanner()._nextTransactionChannelHint(); + const spanner = this._getSpanner(); + this._channelHint = + typeof spanner._nextTransactionChannelHint === 'function' + ? spanner._nextTransactionChannelHint() + : undefined; } return this._channelHint; } diff --git a/handwritten/spanner/test/index.ts b/handwritten/spanner/test/index.ts index 39f4dfdd24aa..136d80668633 100644 --- a/handwritten/spanner/test/index.ts +++ b/handwritten/spanner/test/index.ts @@ -229,12 +229,7 @@ describe('Spanner', () => { scopes: [], grpc, 'grpc.keepalive_time_ms': 120000, - 'grpc.callInvocationTransformer': - fakeGrpcGcp().gcpCallInvocationTransformer, - 'grpc.channelFactoryOverride': fakeGrpcGcp().gcpChannelFactoryOverride, - 'grpc.gcpApiConfig': { - calledWith_: apiConfig, - }, + 'grpc.use_local_subchannel_pool': 1, }); it('should localize a cached gapic client map', () => { @@ -258,6 +253,30 @@ describe('Spanner', () => { ); }); + it('should use grpc-gcp when a custom grpc.gcpApiConfig is provided', () => { + const customGcpApiConfig = {channelPool: {maxSize: 2}}; + const options = extend({}, OPTIONS, { + 'grpc.gcpApiConfig': customGcpApiConfig, + }); + const spanner = new Spanner(options); + const expectedOptions = extend({}, OPTIONS, { + libName: 'gccl', + libVersion: require('../../package.json').version, + scopes: [], + grpc, + 'grpc.keepalive_time_ms': 120000, + 'grpc.callInvocationTransformer': + fakeGrpcGcp().gcpCallInvocationTransformer, + 'grpc.channelFactoryOverride': fakeGrpcGcp().gcpChannelFactoryOverride, + 'grpc.gcpApiConfig': customGcpApiConfig, + }); + + assert.deepStrictEqual( + getFake(spanner.auth).calledWith_[0], + expectedOptions, + ); + }); + it('should combine and uniquify all gapic client scopes', () => { const expectedScopes = ['a', 'b', 'c']; fakeV1.DatabaseAdminClient.scopes = ['a', 'c']; From 77dea0dc6d0f6490a6cab54d0c226689d118e650 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Thu, 30 Apr 2026 13:09:08 +0530 Subject: [PATCH 4/7] fix tests --- handwritten/spanner/test/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/handwritten/spanner/test/index.ts b/handwritten/spanner/test/index.ts index 136d80668633..81e0d7652a87 100644 --- a/handwritten/spanner/test/index.ts +++ b/handwritten/spanner/test/index.ts @@ -2112,6 +2112,7 @@ describe('Spanner', () => { [CLOUD_RESOURCE_HEADER]: 'header', }, }; + const CLIENT_KEY = `${CONFIG.client}:0`; // eslint-disable-next-line @typescript-eslint/no-explicit-any const FAKE_GAPIC_CLIENT: any = { @@ -2175,7 +2176,7 @@ describe('Spanner', () => { assert.strictEqual(options, spanner.options); setImmediate(() => { - const cachedClient = spanner.clients_.get(CONFIG.client); + const cachedClient = spanner.clients_.get(CLIENT_KEY); assert.strictEqual(cachedClient, FAKE_GAPIC_CLIENT); done(); }); @@ -2190,7 +2191,7 @@ describe('Spanner', () => { fakeV1[CONFIG.client] = () => { throw new Error('Should not have re-created client!'); }; - spanner.clients_.set(CONFIG.client, FAKE_GAPIC_CLIENT); + spanner.clients_.set(CLIENT_KEY, FAKE_GAPIC_CLIENT); spanner.prepareGapicRequest_(CONFIG, assert.ifError); }); From b5cd782803e73ccec6584ab7f2ef7fe98ac4bb01 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Thu, 30 Apr 2026 07:51:49 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20po?= =?UTF-8?q?st-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- handwritten/spanner/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/handwritten/spanner/src/index.ts b/handwritten/spanner/src/index.ts index 28ef2f21fc1a..2e05aee43f2f 100644 --- a/handwritten/spanner/src/index.ts +++ b/handwritten/spanner/src/index.ts @@ -446,7 +446,10 @@ class Spanner extends GrpcService { 'grpc.gcpApiConfig': grpcGcp.createGcpApiConfig(gcpApiConfig), }); } - options = Object.assign(defaultOptions, options || {}) as {} as SpannerOptions; + options = Object.assign( + defaultOptions, + options || {}, + ) as {} as SpannerOptions; if (numChannels) { Object.assign(defaultOptions, { // Keep each generated gRPC channel on its own HTTP/2 transport. From f1297d0b9395044d5b5e4b8d74575fdd33410d53 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Thu, 30 Apr 2026 07:52:09 +0000 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20po?= =?UTF-8?q?st-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- handwritten/spanner/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/handwritten/spanner/src/index.ts b/handwritten/spanner/src/index.ts index 28ef2f21fc1a..2e05aee43f2f 100644 --- a/handwritten/spanner/src/index.ts +++ b/handwritten/spanner/src/index.ts @@ -446,7 +446,10 @@ class Spanner extends GrpcService { 'grpc.gcpApiConfig': grpcGcp.createGcpApiConfig(gcpApiConfig), }); } - options = Object.assign(defaultOptions, options || {}) as {} as SpannerOptions; + options = Object.assign( + defaultOptions, + options || {}, + ) as {} as SpannerOptions; if (numChannels) { Object.assign(defaultOptions, { // Keep each generated gRPC channel on its own HTTP/2 transport. From 284dbb1dafff85a14608088275dec383c6694ed9 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Tue, 5 May 2026 12:55:48 +0530 Subject: [PATCH 7/7] only use num_channels when explicitly set --- handwritten/spanner/src/index.ts | 5 +---- handwritten/spanner/test/index.ts | 32 +++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/handwritten/spanner/src/index.ts b/handwritten/spanner/src/index.ts index 2e05aee43f2f..0d364d210bca 100644 --- a/handwritten/spanner/src/index.ts +++ b/handwritten/spanner/src/index.ts @@ -408,14 +408,11 @@ class Spanner extends GrpcService { } constructor(options?: SpannerOptions) { - const hasCustomerGcpApiConfig = - options && options['grpc.gcpApiConfig'] !== undefined; const requestedNumChannels = options?.numChannels && options.numChannels > 0 ? Math.floor(options.numChannels) : 0; - const numChannels = - requestedNumChannels || (hasCustomerGcpApiConfig ? 0 : 4); + const numChannels = requestedNumChannels; const scopes: Array<{}> = []; const clientClasses = [ v1.DatabaseAdminClient, diff --git a/handwritten/spanner/test/index.ts b/handwritten/spanner/test/index.ts index 81e0d7652a87..4595048507bd 100644 --- a/handwritten/spanner/test/index.ts +++ b/handwritten/spanner/test/index.ts @@ -229,7 +229,12 @@ describe('Spanner', () => { scopes: [], grpc, 'grpc.keepalive_time_ms': 120000, - 'grpc.use_local_subchannel_pool': 1, + 'grpc.callInvocationTransformer': + fakeGrpcGcp().gcpCallInvocationTransformer, + 'grpc.channelFactoryOverride': fakeGrpcGcp().gcpChannelFactoryOverride, + 'grpc.gcpApiConfig': { + calledWith_: apiConfig, + }, }); it('should localize a cached gapic client map', () => { @@ -253,6 +258,26 @@ describe('Spanner', () => { ); }); + it('should use numChannels instead of grpc-gcp when explicitly provided', () => { + const options = extend({}, OPTIONS, { + numChannels: 4, + }); + const spanner = new Spanner(options); + const expectedOptions = extend({}, OPTIONS, { + libName: 'gccl', + libVersion: require('../../package.json').version, + scopes: [], + grpc, + 'grpc.keepalive_time_ms': 120000, + 'grpc.use_local_subchannel_pool': 1, + }); + + assert.deepStrictEqual( + getFake(spanner.auth).calledWith_[0], + expectedOptions, + ); + }); + it('should use grpc-gcp when a custom grpc.gcpApiConfig is provided', () => { const customGcpApiConfig = {channelPool: {maxSize: 2}}; const options = extend({}, OPTIONS, { @@ -2112,7 +2137,6 @@ describe('Spanner', () => { [CLOUD_RESOURCE_HEADER]: 'header', }, }; - const CLIENT_KEY = `${CONFIG.client}:0`; // eslint-disable-next-line @typescript-eslint/no-explicit-any const FAKE_GAPIC_CLIENT: any = { @@ -2176,7 +2200,7 @@ describe('Spanner', () => { assert.strictEqual(options, spanner.options); setImmediate(() => { - const cachedClient = spanner.clients_.get(CLIENT_KEY); + const cachedClient = spanner.clients_.get(CONFIG.client); assert.strictEqual(cachedClient, FAKE_GAPIC_CLIENT); done(); }); @@ -2191,7 +2215,7 @@ describe('Spanner', () => { fakeV1[CONFIG.client] = () => { throw new Error('Should not have re-created client!'); }; - spanner.clients_.set(CLIENT_KEY, FAKE_GAPIC_CLIENT); + spanner.clients_.set(CONFIG.client, FAKE_GAPIC_CLIENT); spanner.prepareGapicRequest_(CONFIG, assert.ifError); });