From c0a43638c81dd769cc55e021cc4cf1d93db8a72a Mon Sep 17 00:00:00 2001 From: surbhigarg92 Date: Mon, 8 Jan 2024 12:58:16 +0000 Subject: [PATCH] feat: Support for Directed Reads (#1966) --- .gitignore | 2 + README.md | 1 + protos/protos.d.ts | 2 +- protos/protos.js | 2 +- samples/README.md | 18 +++ samples/directed-reads.js | 117 ++++++++++++++++++ samples/system-test/spanner.test.js | 20 ++++ src/batch-transaction.ts | 2 +- src/index.ts | 12 ++ src/session-pool.ts | 2 + src/transaction.ts | 40 +++++++ system-test/spanner.ts | 67 +++++++++++ test/batch-transaction.ts | 56 ++++++++- test/database.ts | 91 ++++++++++++-- test/index.ts | 22 ++++ test/instance-config.ts | 2 + test/transaction.ts | 179 ++++++++++++++++++++++++++++ 17 files changed, 617 insertions(+), 18 deletions(-) create mode 100644 samples/directed-reads.js diff --git a/.gitignore b/.gitignore index d4f03a0df..fd7af019d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ **/node_modules /.coverage /coverage +/.idea +/.vscode /.nyc_output /docs/ /out/ diff --git a/README.md b/README.md index a23600c32..3b3e59766 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ Samples are in the [`samples/`](https://github.com/googleapis/nodejs-spanner/tre | Updates the default leader of an existing database | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/database-update-default-leader.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/database-update-default-leader.js,samples/README.md) | | Updates a Cloud Spanner Database. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/database-update.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/database-update.js,samples/README.md) | | Datatypes | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/datatypes.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/datatypes.js,samples/README.md) | +| Runs an execute sql request with directed read options | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/directed-reads.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/directed-reads.js,samples/README.md) | | Delete using DML returning. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/dml-returning-delete.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/dml-returning-delete.js,samples/README.md) | | Insert using DML returning. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/dml-returning-insert.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/dml-returning-insert.js,samples/README.md) | | Update using DML returning. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/dml-returning-update.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/dml-returning-update.js,samples/README.md) | diff --git a/protos/protos.d.ts b/protos/protos.d.ts index 99ec6fff4..f020eea26 100644 --- a/protos/protos.d.ts +++ b/protos/protos.d.ts @@ -1,4 +1,4 @@ -// Copyright 2023 Google LLC +// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/protos/protos.js b/protos/protos.js index b2ec5546f..f2790bb77 100644 --- a/protos/protos.js +++ b/protos/protos.js @@ -1,4 +1,4 @@ -// Copyright 2023 Google LLC +// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/samples/README.md b/samples/README.md index 5c15b0c31..f4097374e 100644 --- a/samples/README.md +++ b/samples/README.md @@ -37,6 +37,7 @@ and automatic, synchronous replication for high availability. * [Updates the default leader of an existing database](#updates-the-default-leader-of-an-existing-database) * [Updates a Cloud Spanner Database.](#updates-a-cloud-spanner-database.) * [Datatypes](#datatypes) + * [Runs an execute sql request with directed read options](#runs-an-execute-sql-request-with-directed-read-options) * [Delete using DML returning.](#delete-using-dml-returning.) * [Insert using DML returning.](#insert-using-dml-returning.) * [Update using DML returning.](#update-using-dml-returning.) @@ -517,6 +518,23 @@ __Usage:__ +### Runs an execute sql request with directed read options + +View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/directed-reads.js). + +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/directed-reads.js,samples/README.md) + +__Usage:__ + + +`node directed-reads.js ` + + +----- + + + + ### Delete using DML returning. View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/dml-returning-delete.js). diff --git a/samples/directed-reads.js b/samples/directed-reads.js new file mode 100644 index 000000000..83e202477 --- /dev/null +++ b/samples/directed-reads.js @@ -0,0 +1,117 @@ +// Copyright 2024 Google LLC +// +// 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. + +// sample-metadata: +// title: Runs an execute sql request with directed read options +// usage: node directed-reads.js + +'use strict'; + +function main( + instanceId = 'my-instance', + databaseId = 'my-database', + projectId = 'my-project-id' +) { + // [START spanner_directed_read] + // Imports the Google Cloud Spanner client library + const {Spanner, protos} = require('@google-cloud/spanner'); + + // Only one of excludeReplicas or includeReplicas can be set + // Each accepts a list of replicaSelections which contains location and type + // * `location` - The location must be one of the regions within the + // multi-region configuration of your database. + // * `type` - The type of the replica + // Some examples of using replicaSelectors are: + // * `location:us-east1` --> The "us-east1" replica(s) of any available type + // will be used to process the request. + // * `type:READ_ONLY` --> The "READ_ONLY" type replica(s) in nearest + //. available location will be used to process the + // request. + // * `location:us-east1 type:READ_ONLY` --> The "READ_ONLY" type replica(s) + // in location "us-east1" will be used to process + // the request. + // includeReplicas also contains an option for autoFailover which when set + // Spanner will not route requests to a replica outside the + // includeReplicas list when all the specified replicas are unavailable + // or unhealthy. The default value is `false` + const directedReadOptionsForClient = { + excludeReplicas: { + replicaSelections: [ + { + location: 'us-east4', + }, + ], + }, + }; + + // Instantiates a client with directedReadOptions + const spanner = new Spanner({ + projectId: projectId, + directedReadOptions: directedReadOptionsForClient, + }); + + async function spannerDirectedReads() { + // Gets a reference to a Cloud Spanner instance and backup + const instance = spanner.instance(instanceId); + const database = instance.database(databaseId); + const directedReadOptionsForRequest = { + includeReplicas: { + replicaSelections: [ + { + type: protos.google.spanner.v1.DirectedReadOptions.ReplicaSelection + .Type.READ_ONLY, + }, + ], + autoFailoverDisabled: true, + }, + }; + + await database.getSnapshot(async (err, transaction) => { + if (err) { + console.error(err); + return; + } + try { + // Read rows while passing directedReadOptions directly to the query. + // These will override the options passed at Client level. + const [rows] = await transaction.run({ + sql: 'SELECT SingerId, AlbumId, AlbumTitle FROM Albums', + directedReadOptions: directedReadOptionsForRequest, + }); + rows.forEach(row => { + const json = row.toJSON(); + console.log( + `SingerId: ${json.SingerId}, AlbumId: ${json.AlbumId}, AlbumTitle: ${json.AlbumTitle}` + ); + }); + console.log( + 'Successfully executed read-only transaction with directedReadOptions' + ); + } catch (err) { + console.error('ERROR:', err); + } finally { + transaction.end(); + // Close the database when finished. + await database.close(); + } + }); + } + spannerDirectedReads(); + // [END spanner_directed_read] +} +process.on('unhandledRejection', err => { + console.error(err.message); + process.exitCode = 1; +}); +main(...process.argv.slice(2)); diff --git a/samples/system-test/spanner.test.js b/samples/system-test/spanner.test.js index 0b73b238d..04645c9ff 100644 --- a/samples/system-test/spanner.test.js +++ b/samples/system-test/spanner.test.js @@ -1985,5 +1985,25 @@ describe('Spanner', () => { ) ); }); + + // directed_read_options + it('should run read-only transaction with directed read options set', async () => { + const output = execSync( + `node directed-reads.js ${INSTANCE_ID} ${DATABASE_ID} ${PROJECT_ID}` + ); + console.log(output); + assert.match( + output, + new RegExp( + 'SingerId: 2, AlbumId: 2, AlbumTitle: Forever Hold your Peace' + ) + ); + assert.match( + output, + new RegExp( + 'Successfully executed read-only transaction with directedReadOptions' + ) + ); + }); }); }); diff --git a/src/batch-transaction.ts b/src/batch-transaction.ts index c74432088..842a82cdc 100644 --- a/src/batch-transaction.ts +++ b/src/batch-transaction.ts @@ -20,7 +20,7 @@ import * as extend from 'extend'; import * as is from 'is'; import {Snapshot} from './transaction'; import {google} from '../protos/protos'; -import {Session, Database, Spanner} from '.'; +import {Session, Database} from '.'; import { CLOUD_RESOURCE_HEADER, addLeaderAwareRoutingHeader, diff --git a/src/index.ts b/src/index.ts index 8fb98151b..d2901bf71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,6 +110,9 @@ export type GetInstanceConfigOperationsCallback = PagedCallback< * Session pool configuration options. * @property {boolean} [routeToLeaderEnabled=True] If set to false leader aware routing will be disabled. * Disabling leader aware routing would route all requests in RW/PDML transactions to any region. + * @property {google.spanner.v1.IDirectedReadOptions} [directedReadOptions] Sets the DirectedReadOptions for all ReadRequests and ExecuteSqlRequests for the Client. + * Indicates which replicas or regions should be used for non-transactional reads or queries. + * DirectedReadOptions won't be set for readWrite transactions" */ export interface SpannerOptions extends GrpcClientOptions { apiEndpoint?: string; @@ -117,6 +120,7 @@ export interface SpannerOptions extends GrpcClientOptions { port?: number; sslCreds?: grpc.ChannelCredentials; routeToLeaderEnabled?: boolean; + directedReadOptions?: google.spanner.v1.IDirectedReadOptions | null; } export interface RequestConfig { client: string; @@ -217,6 +221,7 @@ class Spanner extends GrpcService { projectFormattedName_: string; resourceHeader_: {[k: string]: string}; routeToLeaderEnabled = true; + directedReadOptions: google.spanner.v1.IDirectedReadOptions | null; /** * Placeholder used to auto populate a column with the commit timestamp. @@ -291,6 +296,12 @@ class Spanner extends GrpcService { }, options || {} ) as {} as SpannerOptions; + + const directedReadOptions = options.directedReadOptions + ? options.directedReadOptions + : null; + delete options.directedReadOptions; + const emulatorHost = Spanner.getSpannerEmulatorHost(); if ( emulatorHost && @@ -332,6 +343,7 @@ class Spanner extends GrpcService { this.resourceHeader_ = { [CLOUD_RESOURCE_HEADER]: this.projectFormattedName_, }; + this.directedReadOptions = directedReadOptions; } /** Closes this Spanner client and cleans up all resources used by it. */ diff --git a/src/session-pool.ts b/src/session-pool.ts index 0acaae715..cfc2a4360 100644 --- a/src/session-pool.ts +++ b/src/session-pool.ts @@ -1081,7 +1081,9 @@ export class SessionPool extends EventEmitter implements SessionPoolInterface { * @private */ _stopHouseKeeping(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any clearInterval(this._pingHandle as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any clearInterval(this._evictHandle as any); } } diff --git a/src/transaction.ts b/src/transaction.ts index 3a64b7730..4eb30bdda 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -86,6 +86,7 @@ export interface ExecuteSqlRequest extends Statement, RequestOptions { queryOptions?: IQueryOptions; requestOptions?: Omit; dataBoostEnabled?: boolean | null; + directedReadOptions?: google.spanner.v1.IDirectedReadOptions; } export interface KeyRange { @@ -107,6 +108,7 @@ export interface ReadRequest extends RequestOptions { partitionToken?: Uint8Array | null; requestOptions?: Omit; dataBoostEnabled?: boolean | null; + directedReadOptions?: google.spanner.v1.IDirectedReadOptions; } export interface BatchUpdateError extends grpc.ServiceError { @@ -457,6 +459,8 @@ export class Snapshot extends EventEmitter { * PartitionReadRequest message used to create this partition_token. * @property {google.spanner.v1.RequestOptions} [requestOptions] * Common options for this request. + * @property {google.spanner.v1.IDirectedReadOptions} [directedReadOptions] + * Indicates which replicas or regions should be used for non-transactional reads or queries. * @property {object} [gaxOptions] * Call options. See {@link https://googleapis.dev/nodejs/google-gax/latest/interfaces/CallOptions.html|CallOptions} * for more details. @@ -591,6 +595,10 @@ export class Snapshot extends EventEmitter { transaction.singleUse = this._options; } + const directedReadOptions = this._getDirectedReadOptions( + request.directedReadOptions + ); + request = Object.assign({}, request); delete request.gaxOptions; @@ -600,6 +608,7 @@ export class Snapshot extends EventEmitter { delete request.keys; delete request.ranges; delete request.requestOptions; + delete request.directedReadOptions; const reqOpts: spannerClient.spanner.v1.IReadRequest = Object.assign( request, @@ -610,6 +619,7 @@ export class Snapshot extends EventEmitter { this.requestOptions?.transactionTag ?? undefined, requestOptions ), + directedReadOptions: directedReadOptions, transaction, table, keySet, @@ -993,6 +1003,8 @@ export class Snapshot extends EventEmitter { * that it is not ready for any more data. Increase this value if you * experience 'Stream is still not ready to receive data' errors as a * result of a slow writer in your receiving stream. + * @property {object} [directedReadOptions] + * Indicates which replicas or regions should be used for non-transactional reads or queries. */ /** * Create a readable object stream to receive resulting rows from a SQL @@ -1068,6 +1080,10 @@ export class Snapshot extends EventEmitter { query; let reqOpts; + const directedReadOptions = this._getDirectedReadOptions( + query.directedReadOptions + ); + const sanitizeRequest = () => { query = query as ExecuteSqlRequest; const {params, paramTypes} = Snapshot.encodeParams(query); @@ -1085,6 +1101,7 @@ export class Snapshot extends EventEmitter { delete query.maxResumeRetries; delete query.requestOptions; delete query.types; + delete query.directedReadOptions; reqOpts = Object.assign(query, { session: this.session.formattedName_!, @@ -1094,6 +1111,7 @@ export class Snapshot extends EventEmitter { this.requestOptions?.transactionTag ?? undefined, requestOptions ), + directedReadOptions: directedReadOptions, transaction, params, paramTypes, @@ -1288,6 +1306,28 @@ export class Snapshot extends EventEmitter { return {params, paramTypes}; } + /** + * Get directed read options + * @private + * @param {google.spanner.v1.IDirectedReadOptions} directedReadOptions Request directedReadOptions object. + */ + protected _getDirectedReadOptions( + directedReadOptions: + | google.spanner.v1.IDirectedReadOptions + | null + | undefined + ) { + if ( + !directedReadOptions && + this._getSpanner().directedReadOptions && + this._options.readOnly + ) { + return this._getSpanner().directedReadOptions; + } + + return directedReadOptions; + } + /** * Update transaction properties from the response. * diff --git a/system-test/spanner.ts b/system-test/spanner.ts index fa5878f04..d2bde030f 100644 --- a/system-test/spanner.ts +++ b/system-test/spanner.ts @@ -30,6 +30,7 @@ import { Instance, InstanceConfig, Session, + protos, } from '../src'; import {Key} from '../src/table'; import { @@ -7099,6 +7100,39 @@ describe('Spanner', () => { postgreSqlRecords ); }); + + it('GOOGLE_STANDARD_SQL should pass directedReadOptions at query level read-only transactions', function (done) { + if (IS_EMULATOR_ENABLED) { + this.skip(); + } + const directedReadOptionsForRequest = { + includeReplicas: { + replicaSelections: [ + { + type: protos.google.spanner.v1.DirectedReadOptions + .ReplicaSelection.Type.READ_ONLY, + }, + ], + autoFailoverDisabled: true, + }, + }; + + DATABASE.getSnapshot((err, transaction) => { + assert.ifError(err); + transaction!.run( + { + sql: `SELECT * FROM ${TABLE_NAME}`, + directedReadOptions: directedReadOptionsForRequest, + }, + (err, rows) => { + assert.ifError(err); + assert.strictEqual(rows.length, googleSqlRecords.length); + transaction!.end(); + done(); + } + ); + }); + }); }); describe('dml', () => { @@ -8670,6 +8704,39 @@ describe('Spanner', () => { } deadlineErrorInsteadOfAbort(done, PG_DATABASE, postgreSqlTable); }); + + it('GOOGLE_STANDARD_SQL should throw error when directedReadOptions at query level is set with read-write transactions', function (done) { + if (IS_EMULATOR_ENABLED) { + this.skip(); + } + const directedReadOptionsForRequest = { + includeReplicas: { + replicaSelections: [ + { + type: protos.google.spanner.v1.DirectedReadOptions + .ReplicaSelection.Type.READ_WRITE, + }, + ], + autoFailoverDisabled: true, + }, + }; + + DATABASE.runTransaction((err, transaction) => { + const expectedErrorMessage = + 'Directed reads can only be performed in a read-only transaction.'; + transaction!.run( + { + sql: `SELECT * FROM ${googleSqlTable.name}`, + directedReadOptions: directedReadOptionsForRequest, + }, + err => { + assert.strictEqual(err?.details, expectedErrorMessage); + } + ); + transaction!.end(); + done(); + }); + }); }); describe('batch transactions', () => { diff --git a/test/batch-transaction.ts b/test/batch-transaction.ts index 577877b4e..87ad8343c 100644 --- a/test/batch-transaction.ts +++ b/test/batch-transaction.ts @@ -25,6 +25,7 @@ import * as proxyquire from 'proxyquire'; import * as sinon from 'sinon'; import {Session, Database, Spanner} from '../src'; +import {protos} from '../src'; import * as bt from '../src/batch-transaction'; import {PartialResultStream} from '../src/partial-result-stream'; import { @@ -144,12 +145,27 @@ describe('BatchTransaction', () => { describe('createQueryPartitions', () => { const GAX_OPTS = {a: 'b'}; + + const fakeDirectedReadOptionsForRequest = { + includeReplicas: { + replicaSelections: [ + { + location: 'us-west1', + type: protos.google.spanner.v1.DirectedReadOptions.ReplicaSelection + .Type.READ_WRITE, + }, + ], + autoFailoverDisabled: true, + }, + }; + const QUERY = { sql: 'SELECT * FROM Singers', gaxOptions: GAX_OPTS, params: {}, types: {}, dataBoostEnabled: true, + directedReadOptions: fakeDirectedReadOptionsForRequest, }; it('should make the correct request', () => { @@ -157,6 +173,7 @@ describe('BatchTransaction', () => { params: {a: 'b'}, paramTypes: {a: 'string'}, dataBoostEnabled: true, + directedReadOptions: fakeDirectedReadOptionsForRequest, }; const expectedQuery = Object.assign({sql: QUERY.sql}, fakeParams); @@ -301,12 +318,27 @@ describe('BatchTransaction', () => { describe('createReadPartitions', () => { const GAX_OPTS = {}; + + const fakeDirectedReadOptionsForRequest = { + includeReplicas: { + replicaSelections: [ + { + location: 'us-west1', + type: protos.google.spanner.v1.DirectedReadOptions.ReplicaSelection + .Type.READ_WRITE, + }, + ], + autoFailoverDisabled: true, + }, + }; + const QUERY = { table: 'abc', keys: ['a', 'b'], ranges: [{}, {}], gaxOptions: GAX_OPTS, dataBoostEnabled: true, + directedReadOptions: fakeDirectedReadOptionsForRequest, }; it('should make the correct request', () => { @@ -315,6 +347,7 @@ describe('BatchTransaction', () => { table: QUERY.table, keySet: fakeKeySet, dataBoostEnabled: true, + directedReadOptions: fakeDirectedReadOptionsForRequest, }; const stub = sandbox.stub(batchTransaction, 'createPartitions_'); @@ -338,6 +371,18 @@ describe('BatchTransaction', () => { }); describe('execute', () => { + const directedReadOptionsForRequest = { + includeReplicas: { + replicaSelections: [ + { + type: protos.google.spanner.v1.DirectedReadOptions.ReplicaSelection + .Type.READ_ONLY, + }, + ], + autoFailoverDisabled: true, + }, + }; + it('should make read requests for read partitions', () => { const partition = {table: 'abc'}; const stub = sandbox.stub(batchTransaction, 'read'); @@ -359,8 +404,12 @@ describe('BatchTransaction', () => { assert.strictEqual(query, partition); }); - it('should make read requests for read partitions with data boost enabled', () => { - const partition = {table: 'abc', dataBoostEnabled: true}; + it('should make read requests for read partitions with request options', () => { + const partition = { + table: 'abc', + dataBoostEnabled: true, + directedReadOptions: directedReadOptionsForRequest, + }; const stub = sandbox.stub(batchTransaction, 'read'); batchTransaction.execute(partition, assert.ifError); @@ -370,10 +419,11 @@ describe('BatchTransaction', () => { assert.strictEqual(options, partition); }); - it('should make query requests for non-read partitions with data boost enabled', () => { + it('should make query requests for non-read partitions with request options', () => { const partition = { sql: 'SELECT * FROM Singers', dataBoostEnabled: true, + directedReadOptions: directedReadOptionsForRequest, }; const stub = sandbox.stub(batchTransaction, 'run'); diff --git a/test/database.ts b/test/database.ts index 1617172ba..bd697cd9e 100644 --- a/test/database.ts +++ b/test/database.ts @@ -36,6 +36,7 @@ import { LEADER_AWARE_ROUTING_HEADER, } from '../src/common'; import {google} from '../protos/protos'; +import {protos} from '../src'; import * as inst from '../src/instance'; import RequestOptions = google.spanner.v1.RequestOptions; import EncryptionType = google.spanner.admin.database.v1.RestoreDatabaseEncryptionConfig.EncryptionType; @@ -88,10 +89,14 @@ class FakeSession { this.calledWith_ = arguments; } partitionedDml(): FakeTransaction { - return new FakeTransaction(); + return new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.PartitionedDml + ); } snapshot(): FakeTransaction { - return new FakeTransaction(); + return new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadOnly + ); } } @@ -115,8 +120,10 @@ class FakeTable { class FakeTransaction extends EventEmitter { calledWith_: IArguments; - constructor() { + _options!: google.spanner.v1.ITransactionOptions; + constructor(options) { super(); + this._options = options; this.calledWith_ = arguments; } begin() {} @@ -437,6 +444,7 @@ describe('Database', () => { const error = new Error('err'); const response = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(database, 'request').callsFake((_, cb: any) => { cb(error, response); }); @@ -460,6 +468,7 @@ describe('Database', () => { stub.withArgs(session.name).returns(fakeSessions[i]); }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(database, 'request').callsFake((_, cb: any) => { cb(null, response); }); @@ -1572,8 +1581,12 @@ describe('Database', () => { fakePool = database.pool_; fakeSession = new FakeSession(); fakeSession2 = new FakeSession(); - fakeSnapshot = new FakeTransaction(); - fakeSnapshot2 = new FakeTransaction(); + fakeSnapshot = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadOnly + ); + fakeSnapshot2 = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadOnly + ); fakeStream = through.obj(); fakeStream2 = through.obj(); @@ -1935,7 +1948,9 @@ describe('Database', () => { beforeEach(() => { fakePool = database.pool_; fakeSession = new FakeSession(); - fakeSnapshot = new FakeTransaction(); + fakeSnapshot = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadOnly + ); beginSnapshotStub = ( sandbox.stub(fakeSnapshot, 'begin') as sinon.SinonStub @@ -2009,7 +2024,9 @@ describe('Database', () => { } as MockError; const fakeSession2 = new FakeSession(); - const fakeSnapshot2 = new FakeTransaction(); + const fakeSnapshot2 = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadOnly + ); (sandbox.stub(fakeSnapshot2, 'begin') as sinon.SinonStub).callsFake( callback => callback(null) ); @@ -2072,7 +2089,9 @@ describe('Database', () => { beforeEach(() => { fakePool = database.pool_; fakeSession = new FakeSession(); - fakeTransaction = new FakeTransaction(); + fakeTransaction = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadWrite + ); getSessionStub = ( sandbox.stub(fakePool, 'getSession') as sinon.SinonStub @@ -2427,16 +2446,33 @@ describe('Database', () => { let fakePool: FakeSessionPool; let fakeSession: FakeSession; - let fakePartitionedDml: FakeTransaction; + let fakePartitionedDml = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.PartitionedDml + ); let getSessionStub; let beginStub; let runUpdateStub; + const fakeDirectedReadOptions = { + includeReplicas: { + replicaSelections: [ + { + location: 'us-west1', + type: protos.google.spanner.v1.DirectedReadOptions.ReplicaSelection + .Type.READ_WRITE, + }, + ], + autoFailoverDisabled: true, + }, + }; + beforeEach(() => { fakePool = database.pool_; fakeSession = new FakeSession(); - fakePartitionedDml = new FakeTransaction(); + fakePartitionedDml = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.PartitionedDml + ); getSessionStub = ( sandbox.stub(fakePool, 'getSession') as sinon.SinonStub @@ -2543,11 +2579,40 @@ describe('Database', () => { }); assert.ok(fakeCallback.calledOnce); }); + + it('should ignore directedReadOptions set for client', () => { + const fakeCallback = sandbox.spy(); + + database.parent.parent = { + routeToLeaderEnabled: true, + directedReadOptions: fakeDirectedReadOptions, + }; + + database.runPartitionedUpdate( + { + sql: QUERY.sql, + params: QUERY.params, + requestOptions: {priority: RequestOptions.Priority.PRIORITY_LOW}, + }, + fakeCallback + ); + + const [query] = runUpdateStub.lastCall.args; + + assert.deepStrictEqual(query, { + sql: QUERY.sql, + params: QUERY.params, + requestOptions: {priority: RequestOptions.Priority.PRIORITY_LOW}, + }); + assert.ok(fakeCallback.calledOnce); + }); }); describe('runTransaction', () => { const SESSION = new FakeSession(); - const TRANSACTION = new FakeTransaction(); + const TRANSACTION = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadWrite + ); let pool: FakeSessionPool; @@ -2631,7 +2696,9 @@ describe('Database', () => { describe('runTransactionAsync', () => { const SESSION = new FakeSession(); - const TRANSACTION = new FakeTransaction(); + const TRANSACTION = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadWrite + ); let pool: FakeSessionPool; diff --git a/test/index.ts b/test/index.ts index a37b92404..5f8df6e04 100644 --- a/test/index.ts +++ b/test/index.ts @@ -29,6 +29,7 @@ import * as pfy from '@google-cloud/promisify'; import {grpc} from 'google-gax'; import * as sinon from 'sinon'; import * as spnr from '../src'; +import {protos} from '../src'; import {Duplex} from 'stream'; import {CreateInstanceRequest, CreateInstanceConfigRequest} from '../src/index'; import { @@ -281,6 +282,26 @@ describe('Spanner', () => { assert.strictEqual(spanner.routeToLeaderEnabled, false); }); + it('should optionally accept directedReadOptions', () => { + const fakeDirectedReadOptions = { + includeReplicas: { + replicaSelections: [ + { + location: 'us-west1', + type: protos.google.spanner.v1.DirectedReadOptions + .ReplicaSelection.Type.READ_ONLY, + }, + ], + autoFailoverDisabled: true, + }, + }; + + const spanner = new Spanner({ + directedReadOptions: fakeDirectedReadOptions, + }); + assert.strictEqual(spanner.directedReadOptions, fakeDirectedReadOptions); + }); + it('should set projectFormattedName_', () => { assert.strictEqual( spanner.projectFormattedName_, @@ -1105,6 +1126,7 @@ describe('Spanner', () => { }); it('should throw if the provided config object does not have baseConfig', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const {baseConfig, ...CONFIG_WITHOUT_BASE_CONFIG} = ORIGINAL_CONFIG; assert.throws(() => { spanner.createInstanceConfig(NAME, CONFIG_WITHOUT_BASE_CONFIG!); diff --git a/test/instance-config.ts b/test/instance-config.ts index 8dbb1a9aa..3faf7d992 100644 --- a/test/instance-config.ts +++ b/test/instance-config.ts @@ -253,6 +253,7 @@ describe('InstanceConfig', () => { it('should call getInstanceConfig', done => { const options = {}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars sandbox.stub(SPANNER, 'getInstanceConfig').callsFake(_ => done()); instanceConfig.get(options, assert.ifError); @@ -270,6 +271,7 @@ describe('InstanceConfig', () => { }); it('should not require an options object', done => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars sandbox.stub(SPANNER, 'getInstanceConfig').callsFake(_ => done()); instanceConfig.get(assert.ifError); }); diff --git a/test/transaction.ts b/test/transaction.ts index 45cd9dbf6..36d8c6988 100644 --- a/test/transaction.ts +++ b/test/transaction.ts @@ -22,6 +22,7 @@ import {common as p} from 'protobufjs'; import * as proxyquire from 'proxyquire'; import * as sinon from 'sinon'; +import {protos} from '../src'; import {codec} from '../src/codec'; import {google} from '../protos/protos'; import { @@ -45,6 +46,7 @@ describe('Transaction', () => { const SPANNER = { routeToLeaderEnabled: true, + directedReadOptions: {}, }; const INSTANCE = { @@ -66,6 +68,19 @@ describe('Transaction', () => { const PARTIAL_RESULT_STREAM = sandbox.stub(); const PROMISIFY_ALL = sandbox.stub(); + const fakeDirectedReadOptions = { + includeReplicas: { + replicaSelections: [ + { + location: 'us-west1', + type: protos.google.spanner.v1.DirectedReadOptions.ReplicaSelection + .Type.READ_ONLY, + }, + ], + autoFailoverDisabled: true, + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let Snapshot; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -304,6 +319,7 @@ describe('Transaction', () => { keys: ['a', 'b', 'c'], ranges: [{}, {}], columns: ['name'], + directedReadOptions: fakeDirectedReadOptions, }; const expectedRequest = { @@ -314,6 +330,7 @@ describe('Transaction', () => { keySet: fakeKeySet, resumeToken: undefined, columns: ['name'], + directedReadOptions: fakeDirectedReadOptions, }; sandbox @@ -392,6 +409,70 @@ describe('Transaction', () => { assert.deepStrictEqual(options, fakeOptions); }); + + it('should accept directedReadOptions set for client', () => { + const id = 'transaction-id-123'; + SESSION.parent.parent.parent = { + routeToLeaderEnabled: true, + directedReadOptions: fakeDirectedReadOptions, + }; + + const expectedRequest = { + session: SESSION_NAME, + requestOptions: {}, + transaction: {id}, + table: TABLE, + keySet: {all: true}, + resumeToken: undefined, + directedReadOptions: fakeDirectedReadOptions, + }; + + snapshot.id = id; + snapshot.createReadStream(TABLE); + + const {reqOpts} = REQUEST_STREAM.lastCall.args[0]; + + assert.deepStrictEqual(reqOpts, expectedRequest); + }); + + it('should override directedReadOptions set at client level when passed at request level', () => { + const id = 'transaction-id-123'; + const fakeDirectedReadOptionsForRequest = { + includeReplicas: { + replicaSelections: [ + { + location: 'us-east1', + }, + ], + }, + }; + + const fakeRequest = { + directedReadOptions: fakeDirectedReadOptionsForRequest, + }; + + SESSION.parent.parent.parent = { + routeToLeaderEnabled: true, + directedReadOptions: fakeDirectedReadOptions, + }; + + const expectedRequest = { + session: SESSION_NAME, + requestOptions: {}, + transaction: {id}, + table: TABLE, + keySet: {all: true}, + resumeToken: undefined, + directedReadOptions: fakeDirectedReadOptionsForRequest, + }; + + snapshot.id = id; + snapshot.createReadStream(TABLE, fakeRequest); + + const {reqOpts} = REQUEST_STREAM.lastCall.args[0]; + + assert.deepStrictEqual(reqOpts, expectedRequest); + }); }); describe('end', () => { @@ -588,6 +669,7 @@ describe('Transaction', () => { types: {a: 'string'}, seqno: 1, queryOptions: {}, + directedReadOptions: fakeDirectedReadOptions, }); const expectedRequest = { @@ -600,6 +682,7 @@ describe('Transaction', () => { seqno: 1, queryOptions: {}, resumeToken: undefined, + directedReadOptions: fakeDirectedReadOptions, }; sandbox.stub(Snapshot, 'encodeParams').withArgs(fakeQuery).returns({ @@ -742,6 +825,102 @@ describe('Transaction', () => { }); assert.ok(!REQUEST_STREAM.called, 'No request should be made'); }); + + it('should accept directedReadOptions set for client', () => { + const id = 'transaction-id-123'; + const fakeParams = {b: 'a'}; + const fakeParamTypes = {b: 'number'}; + SESSION.parent.parent.parent = { + routeToLeaderEnabled: true, + directedReadOptions: fakeDirectedReadOptions, + }; + + const fakeQuery = Object.assign({}, QUERY, { + params: {a: 'b'}, + types: {a: 'string'}, + seqno: 1, + queryOptions: {}, + }); + + const expectedRequest = { + session: SESSION_NAME, + requestOptions: {}, + transaction: {id}, + sql: QUERY.sql, + params: fakeParams, + paramTypes: fakeParamTypes, + seqno: 1, + queryOptions: {}, + resumeToken: undefined, + directedReadOptions: fakeDirectedReadOptions, + }; + + sandbox.stub(Snapshot, 'encodeParams').withArgs(fakeQuery).returns({ + params: fakeParams, + paramTypes: fakeParamTypes, + }); + + snapshot.id = id; + snapshot.runStream(fakeQuery); + + const {reqOpts} = REQUEST_STREAM.lastCall.args[0]; + + assert.deepStrictEqual(reqOpts, expectedRequest); + }); + + it('should override directedReadOptions set at client level when passed for request level', () => { + const id = 'transaction-id-123'; + const fakeParams = {b: 'a'}; + const fakeParamTypes = {b: 'number'}; + + SESSION.parent.parent.parent = { + routeToLeaderEnabled: true, + directedReadOptions: fakeDirectedReadOptions, + }; + + const fakeDirectedReadOptionsForRequest = { + includeReplicas: { + replicaSelections: [ + { + location: 'us-east1', + }, + ], + }, + }; + + const fakeQuery = Object.assign({}, QUERY, { + params: {a: 'b'}, + types: {a: 'string'}, + seqno: 1, + queryOptions: {}, + directedReadOptions: fakeDirectedReadOptionsForRequest, + }); + + const expectedRequest = { + session: SESSION_NAME, + requestOptions: {}, + transaction: {id}, + sql: QUERY.sql, + params: fakeParams, + paramTypes: fakeParamTypes, + seqno: 1, + queryOptions: {}, + resumeToken: undefined, + directedReadOptions: fakeDirectedReadOptionsForRequest, + }; + + sandbox.stub(Snapshot, 'encodeParams').withArgs(fakeQuery).returns({ + params: fakeParams, + paramTypes: fakeParamTypes, + }); + + snapshot.id = id; + snapshot.runStream(fakeQuery); + + const {reqOpts} = REQUEST_STREAM.lastCall.args[0]; + + assert.deepStrictEqual(reqOpts, expectedRequest); + }); }); describe('encodeKeySet', () => {