Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions handwritten/spanner/src/channel-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*!
* Copyright 2026 Google LLC. All Rights Reserved.
*
* 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 {grpc} from 'google-gax';
import grpcGcpModule = require('grpc-gcp');

const grpcGcp = grpcGcpModule(grpc);

export interface ChannelFactoryWithWithoutAffinity {
_withoutAffinity?: unknown;
}

/**
* Creates a delegate for GcpChannelFactory that overrides getAffinityConfig
* to return undefined. This causes grpc-gcp to skip affinity lookups and select
* a channel using its native stream load balancer (getActiveStreamsCount).
*/
export function createChannelFactoryWithoutAffinity(
channelFactory: object,
): object {
const channelFactoryWithoutAffinity = Object.create(channelFactory);
(
channelFactoryWithoutAffinity as {getAffinityConfig?: () => undefined}
).getAffinityConfig = () => undefined;
return channelFactoryWithoutAffinity;
}
Comment thread
olavloite marked this conversation as resolved.
Comment thread
olavloite marked this conversation as resolved.
Comment thread
olavloite marked this conversation as resolved.
Comment thread
olavloite marked this conversation as resolved.

/**
* Custom channel factory override that pre-allocates a static delegate
* without affinity lookup on the factory instance. This avoids any object
* or closure allocations per request.
*/
export function spannerChannelFactoryOverride(
address: string,
credentials: grpc.ChannelCredentials,
options: object,
) {
const channelFactory = grpcGcp.gcpChannelFactoryOverride(
address,
credentials,
options,
);
if (channelFactory) {
(channelFactory as ChannelFactoryWithWithoutAffinity)._withoutAffinity =
createChannelFactoryWithoutAffinity(channelFactory);
}
return channelFactory;
}
Comment thread
olavloite marked this conversation as resolved.

interface SingleUseTransactionArgument {
transaction?: {
singleUse?: unknown;
single_use?: unknown;
};
}

/**
* Intercepts calls before dispatch. For single-use transactions (e.g. single queries),
* routes through the pre-allocated delegate to distribute across channels in the pool.
* For read/write and multi-use transactions, uses the standard channel factory so
* requests adhere to session-to-channel affinity.
*/
export function spannerCallInvocationTransformer<RequestType, ResponseType>(
callProperties: grpc.CallProperties<RequestType, ResponseType>,
): grpc.CallProperties<RequestType, ResponseType> {
if (!callProperties) {
return callProperties;
}
const argument = callProperties.argument as
SingleUseTransactionArgument | undefined;
if (argument?.transaction?.singleUse || argument?.transaction?.single_use) {
const channelFactory =
callProperties.channel as ChannelFactoryWithWithoutAffinity;
if (channelFactory?._withoutAffinity) {
callProperties.channel =
channelFactory._withoutAffinity as typeof callProperties.channel;
}
}
return grpcGcp.gcpCallInvocationTransformer(callProperties);
}
9 changes: 7 additions & 2 deletions handwritten/spanner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const gcpApiConfig = require('./spanner_grpc_config.json');

import {
spannerCallInvocationTransformer,
spannerChannelFactoryOverride,
} from './channel-factory';

export type IOperation = instanceAdmin.longrunning.IOperation;

export type GetInstancesOptions = PagedOptionsWithFilter;
Expand Down Expand Up @@ -169,7 +174,7 @@
>;
observabilityOptions?: ObservabilityOptions;
disableBuiltInMetrics?: boolean;
interceptors?: any[];

Check warning on line 177 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
sessionLabels?: {[key: string]: string};
/**
* The Trusted Cloud Domain (TPC) DNS of the service used to make requests.
Expand Down Expand Up @@ -424,8 +429,8 @@
// Add grpc keep alive setting
'grpc.keepalive_time_ms': 120000,
// Enable grpc-gcp support
'grpc.callInvocationTransformer': grpcGcp.gcpCallInvocationTransformer,
'grpc.channelFactoryOverride': grpcGcp.gcpChannelFactoryOverride,
'grpc.callInvocationTransformer': spannerCallInvocationTransformer,
'grpc.channelFactoryOverride': spannerChannelFactoryOverride,
'grpc.gcpApiConfig': grpcGcp.createGcpApiConfig(gcpApiConfig),
grpc,
},
Expand Down Expand Up @@ -534,7 +539,7 @@
if (!this.clients_.has(clientName)) {
this.clients_.set(
clientName,
new v1[clientName](this.options as ClientOptions),

Check failure on line 542 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unable to validate computed reference to imported namespace 'v1'
);
}
return this.clients_.get(clientName)! as v1.InstanceAdminClient;
Expand All @@ -558,7 +563,7 @@
if (!this.clients_.has(clientName)) {
this.clients_.set(
clientName,
new v1[clientName](this.options as ClientOptions),

Check failure on line 566 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unable to validate computed reference to imported namespace 'v1'
);
}
return this.clients_.get(clientName)! as v1.DatabaseAdminClient;
Expand Down Expand Up @@ -615,9 +620,9 @@

if (callback) {
// process.nextTick prevents Unhandled Promise Rejections if callback throws
res.then(

Check failure on line 623 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Expected catch() or return
() => process.nextTick(() => callback(null)),

Check warning on line 624 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
err => process.nextTick(() => callback(err)),

Check warning on line 625 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
);
} else {
return res;
Expand Down Expand Up @@ -1727,7 +1732,7 @@
const clientName = config.client;
try {
if (!this.clients_.has(clientName)) {
this.clients_.set(clientName, new v1[clientName](this.options));

Check failure on line 1735 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unable to validate computed reference to imported namespace 'v1'
}
} catch (err) {
callback(err, null);
Expand Down Expand Up @@ -1773,7 +1778,7 @@
// Attach the x-goog-spanner-request-id to the currently active span.
attributeXGoogSpannerRequestIdToActiveSpan(config);
}
const interceptors: any[] = [];

Check warning on line 1781 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
if (this._metricsEnabled) {
interceptors.push(MetricInterceptor);
}
Expand Down Expand Up @@ -1828,7 +1833,7 @@
}

return new Promise((resolve, reject) => {
requestFn(...args)

Check warning on line 1836 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid using promises inside of callbacks

Check warning on line 1836 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid using promises inside of callbacks
.then(resolve)
.catch(err => {
injectRequestIDIntoError(config, err as Error);
Expand Down Expand Up @@ -1892,9 +1897,9 @@
} else {
const result = requestFn();
if (result && typeof result.then === 'function') {
result

Check warning on line 1900 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid using promises inside of callbacks

Check warning on line 1900 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid using promises inside of callbacks
.then(val => {
metricsTracer?.recordOperationCompletion();

Check failure on line 1902 in handwritten/spanner/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Each then() should return a value or throw
resolve(val);
})
.catch(error => {
Expand Down
241 changes: 241 additions & 0 deletions handwritten/spanner/test/channel-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/*!
* Copyright 2026 Google LLC. All Rights Reserved.
*
* 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 * as sinon from 'sinon';
import * as proxyquire from 'proxyquire';
import {grpc} from 'google-gax';
import {
createChannelFactoryWithoutAffinity,
spannerCallInvocationTransformer,
spannerChannelFactoryOverride,
ChannelFactoryWithWithoutAffinity,
} from '../src/channel-factory';

describe('ChannelFactory and Transformer', () => {
let sandbox: sinon.SinonSandbox;

beforeEach(() => {
sandbox = sinon.createSandbox();
});

afterEach(() => {
sandbox.restore();
});

describe('createChannelFactoryWithoutAffinity', () => {
it('should create a prototype delegate that returns undefined for getAffinityConfig', () => {
const originalFactory: {
options: {foo: string};
getAffinityConfig(path?: string): {command: string} | undefined;
} = {
options: {foo: 'bar'},
getAffinityConfig() {
return {command: 'BOUND'};
},
};

const delegate = createChannelFactoryWithoutAffinity(
originalFactory,
) as typeof originalFactory;

assert.strictEqual(delegate.getAffinityConfig('/test.Method'), undefined);
assert.strictEqual(
originalFactory.getAffinityConfig('/test.Method')?.command,
'BOUND',
);
assert.strictEqual(delegate.options, originalFactory.options);
assert.strictEqual(Object.getPrototypeOf(delegate), originalFactory);
});
});
Comment thread
olavloite marked this conversation as resolved.

describe('spannerChannelFactoryOverride', () => {
it('should attach _withoutAffinity delegate to the created channel factory', () => {
const channelFactory = spannerChannelFactoryOverride(
'localhost:443',
grpc.credentials.createInsecure(),
{},
) as ChannelFactoryWithWithoutAffinity & {
getAffinityConfig: (path: string) => unknown;
};

assert.ok(channelFactory);
assert.ok(channelFactory._withoutAffinity);
const withoutAffinity = channelFactory._withoutAffinity as {
getAffinityConfig: (path: string) => unknown;
};
assert.strictEqual(typeof withoutAffinity.getAffinityConfig, 'function');
assert.strictEqual(
withoutAffinity.getAffinityConfig('/test.Method'),
undefined,
);
});

it('should return nullish channel factory when gcpChannelFactoryOverride returns nullish', () => {
const proxiedModule = proxyquire('../src/channel-factory', {
'grpc-gcp': () => ({
gcpChannelFactoryOverride: () => null,
gcpCallInvocationTransformer: (props: unknown) => props,
}),
});

const channelFactory = proxiedModule.spannerChannelFactoryOverride(
'localhost:443',
grpc.credentials.createInsecure(),
{},
);

assert.strictEqual(channelFactory, null);
});
});

describe('spannerCallInvocationTransformer', () => {
function createMockCallProperties(argument?: unknown, channel?: unknown) {
return {
argument,
channel: channel ?? {
_withoutAffinity: {name: 'withoutAffinityChannel'},
},
} as unknown as grpc.CallProperties<unknown, unknown>;
}

it('should route through _withoutAffinity for camelCase singleUse transaction', () => {
const mockChannelFactory = {
name: 'originalChannel',
_withoutAffinity: {name: 'withoutAffinityChannel'},
};
const callProps = createMockCallProperties(
{transaction: {singleUse: {readOnly: {}}}},
mockChannelFactory,
);

const transformed = spannerCallInvocationTransformer(callProps);
assert.strictEqual(
transformed.channel,
mockChannelFactory._withoutAffinity,
);
});

it('should route through _withoutAffinity for snake_case single_use transaction', () => {
const mockChannelFactory = {
name: 'originalChannel',
_withoutAffinity: {name: 'withoutAffinityChannel'},
};
const callProps = createMockCallProperties(
{transaction: {single_use: {read_only: {}}}},
mockChannelFactory,
);

const transformed = spannerCallInvocationTransformer(callProps);
assert.strictEqual(
transformed.channel,
mockChannelFactory._withoutAffinity,
);
});

it('should not change channel for multi-use transaction with id', () => {
const mockChannelFactory = {
name: 'originalChannel',
_withoutAffinity: {name: 'withoutAffinityChannel'},
};
const callProps = createMockCallProperties(
{transaction: {id: Buffer.from('tx-123')}},
mockChannelFactory,
);

const transformed = spannerCallInvocationTransformer(callProps);
assert.strictEqual(transformed.channel, mockChannelFactory);
});

it('should not change channel for read-write transaction with begin', () => {
const mockChannelFactory = {
name: 'originalChannel',
_withoutAffinity: {name: 'withoutAffinityChannel'},
};
const callProps = createMockCallProperties(
{transaction: {begin: {readWrite: {}}}},
mockChannelFactory,
);

const transformed = spannerCallInvocationTransformer(callProps);
assert.strictEqual(transformed.channel, mockChannelFactory);
});

it('should not change channel when argument is undefined', () => {
const mockChannelFactory = {
name: 'originalChannel',
_withoutAffinity: {name: 'withoutAffinityChannel'},
};
const callProps = createMockCallProperties(undefined, mockChannelFactory);

const transformed = spannerCallInvocationTransformer(callProps);
assert.strictEqual(transformed.channel, mockChannelFactory);
});

it('should not change channel when argument is null', () => {
const mockChannelFactory = {
name: 'originalChannel',
_withoutAffinity: {name: 'withoutAffinityChannel'},
};
const callProps = createMockCallProperties(null, mockChannelFactory);

const transformed = spannerCallInvocationTransformer(callProps);
assert.strictEqual(transformed.channel, mockChannelFactory);
});

it('should not change channel when argument has no transaction property', () => {
const mockChannelFactory = {
name: 'originalChannel',
_withoutAffinity: {name: 'withoutAffinityChannel'},
};
const callProps = createMockCallProperties(
{name: 'some-session'},
mockChannelFactory,
);

const transformed = spannerCallInvocationTransformer(callProps);
assert.strictEqual(transformed.channel, mockChannelFactory);
});

it('should fall back gracefully when _withoutAffinity is not present on channel', () => {
const mockChannelFactory = {
name: 'originalChannel',
};
const callProps = createMockCallProperties(
{transaction: {singleUse: {readOnly: {}}}},
mockChannelFactory,
);

const transformed = spannerCallInvocationTransformer(callProps);
assert.strictEqual(transformed.channel, mockChannelFactory);
});

it('should return callProperties directly when callProperties is undefined or null', () => {
assert.strictEqual(
spannerCallInvocationTransformer(
undefined as unknown as grpc.CallProperties<unknown, unknown>,
),
undefined,
);
assert.strictEqual(
spannerCallInvocationTransformer(
null as unknown as grpc.CallProperties<unknown, unknown>,
),
null,
);
});
});
});
Loading
Loading