diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index 24943e116..39a62ca6b 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-nodejs:latest - digest: sha256:609822e3c09b7a1bd90b99655904609f162cc15acb4704f1edf778284c36f429 -# created: 2024-10-01T19:34:30.797530443Z + digest: sha256:0d39e59663287ae929c1d4ccf8ebf7cef9946826c9b86eda7e85d8d752dbb584 +# created: 2024-11-21T22:39:44.342569463Z diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml index d4ca94189..cdb1ea185 100644 --- a/.github/release-trigger.yml +++ b/.github/release-trigger.yml @@ -1 +1,2 @@ enabled: true +multiScmName: nodejs-spanner diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4892eb2c5..e20759835 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,10 +9,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [14, 16, 18, 20] + node: [14, 16, 18] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: node --version @@ -29,10 +29,10 @@ jobs: windows: runs-on: windows-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 14 + node-version: 18 - run: npm install --engine-strict - run: npm test env: @@ -40,19 +40,19 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 14 + node-version: 18 - run: npm install - run: npm run lint docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 14 + node-version: 18 - run: npm install - run: npm run docs - uses: JustinBeckwith/linkinator-action@v1 diff --git a/.kokoro/common.cfg b/.kokoro/common.cfg index 02d5a407d..fa8194f36 100644 --- a/.kokoro/common.cfg +++ b/.kokoro/common.cfg @@ -16,7 +16,7 @@ build_file: "nodejs-spanner/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/node:14-user" + value: "gcr.io/cloud-devrel-kokoro-resources/node:18-user" } env_vars: { key: "TRAMPOLINE_BUILD_FILE" diff --git a/.kokoro/continuous/node18/common.cfg b/.kokoro/continuous/node18/common.cfg new file mode 100644 index 000000000..fa8194f36 --- /dev/null +++ b/.kokoro/continuous/node18/common.cfg @@ -0,0 +1,24 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Use the trampoline script to run in docker. +build_file: "nodejs-spanner/.kokoro/trampoline_v2.sh" + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/node:18-user" +} +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-spanner/.kokoro/test.sh" +} diff --git a/.kokoro/continuous/node18/lint.cfg b/.kokoro/continuous/node18/lint.cfg new file mode 100644 index 000000000..629ea4ddf --- /dev/null +++ b/.kokoro/continuous/node18/lint.cfg @@ -0,0 +1,4 @@ +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-spanner/.kokoro/lint.sh" +} diff --git a/.kokoro/continuous/node18/samples-test.cfg b/.kokoro/continuous/node18/samples-test.cfg new file mode 100644 index 000000000..aa8319f41 --- /dev/null +++ b/.kokoro/continuous/node18/samples-test.cfg @@ -0,0 +1,12 @@ +# Download resources for system tests (service account key, etc.) +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-cloud-nodejs" + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-spanner/.kokoro/samples-test.sh" +} + +env_vars: { + key: "SECRET_MANAGER_KEYS" + value: "long-door-651-kokoro-system-test-service-account" +} \ No newline at end of file diff --git a/.kokoro/continuous/node18/system-test.cfg b/.kokoro/continuous/node18/system-test.cfg new file mode 100644 index 000000000..f8dd221bf --- /dev/null +++ b/.kokoro/continuous/node18/system-test.cfg @@ -0,0 +1,12 @@ +# Download resources for system tests (service account key, etc.) +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-cloud-nodejs" + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-spanner/.kokoro/system-test.sh" +} + +env_vars: { + key: "SECRET_MANAGER_KEYS" + value: "long-door-651-kokoro-system-test-service-account" +} \ No newline at end of file diff --git a/.kokoro/continuous/node18/test.cfg b/.kokoro/continuous/node18/test.cfg new file mode 100644 index 000000000..e69de29bb diff --git a/.kokoro/presubmit/node18/common.cfg b/.kokoro/presubmit/node18/common.cfg new file mode 100644 index 000000000..fa8194f36 --- /dev/null +++ b/.kokoro/presubmit/node18/common.cfg @@ -0,0 +1,24 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Use the trampoline script to run in docker. +build_file: "nodejs-spanner/.kokoro/trampoline_v2.sh" + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/node:18-user" +} +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-spanner/.kokoro/test.sh" +} diff --git a/.kokoro/presubmit/node18/samples-test.cfg b/.kokoro/presubmit/node18/samples-test.cfg new file mode 100644 index 000000000..aa8319f41 --- /dev/null +++ b/.kokoro/presubmit/node18/samples-test.cfg @@ -0,0 +1,12 @@ +# Download resources for system tests (service account key, etc.) +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-cloud-nodejs" + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-spanner/.kokoro/samples-test.sh" +} + +env_vars: { + key: "SECRET_MANAGER_KEYS" + value: "long-door-651-kokoro-system-test-service-account" +} \ No newline at end of file diff --git a/.kokoro/presubmit/node18/system-test.cfg b/.kokoro/presubmit/node18/system-test.cfg new file mode 100644 index 000000000..f8dd221bf --- /dev/null +++ b/.kokoro/presubmit/node18/system-test.cfg @@ -0,0 +1,12 @@ +# Download resources for system tests (service account key, etc.) +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-cloud-nodejs" + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-spanner/.kokoro/system-test.sh" +} + +env_vars: { + key: "SECRET_MANAGER_KEYS" + value: "long-door-651-kokoro-system-test-service-account" +} \ No newline at end of file diff --git a/.kokoro/presubmit/node18/test.cfg b/.kokoro/presubmit/node18/test.cfg new file mode 100644 index 000000000..e69de29bb diff --git a/.kokoro/release/docs-devsite.cfg b/.kokoro/release/docs-devsite.cfg index 03801594c..f2122f4bb 100644 --- a/.kokoro/release/docs-devsite.cfg +++ b/.kokoro/release/docs-devsite.cfg @@ -11,7 +11,7 @@ before_action { # doc publications use a Python image. env_vars: { key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/node:14-user" + value: "gcr.io/cloud-devrel-kokoro-resources/node:18-user" } # Download trampoline resources. diff --git a/.kokoro/release/docs.cfg b/.kokoro/release/docs.cfg index fb73026f0..9ae68ddfe 100644 --- a/.kokoro/release/docs.cfg +++ b/.kokoro/release/docs.cfg @@ -11,7 +11,7 @@ before_action { # doc publications use a Python image. env_vars: { key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/node:14-user" + value: "gcr.io/cloud-devrel-kokoro-resources/node:18-user" } # Download trampoline resources. diff --git a/.kokoro/release/docs.sh b/.kokoro/release/docs.sh index 1d8f3f490..e9079a605 100755 --- a/.kokoro/release/docs.sh +++ b/.kokoro/release/docs.sh @@ -16,7 +16,7 @@ set -eo pipefail -# build jsdocs (Python is installed on the Node 10 docker image). +# build jsdocs (Python is installed on the Node 18 docker image). if [[ -z "$CREDENTIALS" ]]; then # if CREDENTIALS are explicitly set, assume we're testing locally # and don't set NPM_CONFIG_PREFIX. diff --git a/.kokoro/release/publish.cfg b/.kokoro/release/publish.cfg index 54f8f8a51..bd526df13 100644 --- a/.kokoro/release/publish.cfg +++ b/.kokoro/release/publish.cfg @@ -30,7 +30,7 @@ build_file: "nodejs-spanner/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/node:14-user" + value: "gcr.io/cloud-devrel-kokoro-resources/node:18-user" } env_vars: { diff --git a/.kokoro/system-test.sh b/.kokoro/system-test.sh index 0b3043d26..a90d5cfec 100755 --- a/.kokoro/system-test.sh +++ b/.kokoro/system-test.sh @@ -49,7 +49,7 @@ npm run system-test # codecov combines coverage across integration and unit tests. Include # the logic below for any environment you wish to collect coverage for: -COVERAGE_NODE=14 +COVERAGE_NODE=18 if npx check-node-version@3.3.0 --silent --node $COVERAGE_NODE; then NYC_BIN=./node_modules/nyc/bin/nyc.js if [ -f "$NYC_BIN" ]; then diff --git a/.kokoro/test.bat b/.kokoro/test.bat index 0bb124052..caf825656 100644 --- a/.kokoro/test.bat +++ b/.kokoro/test.bat @@ -21,7 +21,7 @@ cd .. @rem we upgrade Node.js in the image: SET PATH=%PATH%;/cygdrive/c/Program Files/nodejs/npm -call nvm use v14.17.3 +call nvm use 18 call which node call npm install || goto :error diff --git a/.kokoro/test.sh b/.kokoro/test.sh index 862d478d3..0d9f6392a 100755 --- a/.kokoro/test.sh +++ b/.kokoro/test.sh @@ -39,7 +39,7 @@ npm test # codecov combines coverage across integration and unit tests. Include # the logic below for any environment you wish to collect coverage for: -COVERAGE_NODE=14 +COVERAGE_NODE=18 if npx check-node-version@3.3.0 --silent --node $COVERAGE_NODE; then NYC_BIN=./node_modules/nyc/bin/nyc.js if [ -f "$NYC_BIN" ]; then diff --git a/.kokoro/trampoline_v2.sh b/.kokoro/trampoline_v2.sh index 4d0311212..5d6cfcca5 100755 --- a/.kokoro/trampoline_v2.sh +++ b/.kokoro/trampoline_v2.sh @@ -44,7 +44,7 @@ # the project root. # # Here is an example for running this script. -# TRAMPOLINE_IMAGE=gcr.io/cloud-devrel-kokoro-resources/node:10-user \ +# TRAMPOLINE_IMAGE=gcr.io/cloud-devrel-kokoro-resources/node:18-user \ # TRAMPOLINE_BUILD_FILE=.kokoro/system-test.sh \ # .kokoro/trampoline_v2.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 68af50868..721922886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ [1]: https://www.npmjs.com/package/nodejs-spanner?activeTab=versions +## [7.17.0](https://github.com/googleapis/nodejs-spanner/compare/v7.16.0...v7.17.0) (2024-12-27) + + +### Features + +* Add the last statement option to ExecuteSqlRequest and ExecuteBatchDmlRequest ([#2196](https://github.com/googleapis/nodejs-spanner/issues/2196)) ([223f167](https://github.com/googleapis/nodejs-spanner/commit/223f167c1c9bc4da26155637eabbcabce5487ede)) +* Enable e2e tracing ([#2202](https://github.com/googleapis/nodejs-spanner/issues/2202)) ([3cc257e](https://github.com/googleapis/nodejs-spanner/commit/3cc257e99925594776b9a1886f0173ce2dfe904f)) + + +### Bug Fixes + +* Span events Issue 2166 ([#2184](https://github.com/googleapis/nodejs-spanner/issues/2184)) ([97ed577](https://github.com/googleapis/nodejs-spanner/commit/97ed5776dbdf5e90f8398fffea08e2a968045f9b)) + ## [7.16.0](https://github.com/googleapis/nodejs-spanner/compare/v7.15.0...v7.16.0) (2024-11-09) diff --git a/OBSERVABILITY.md b/OBSERVABILITY.md index 71ab18226..0d03b06fb 100644 --- a/OBSERVABILITY.md +++ b/OBSERVABILITY.md @@ -80,7 +80,27 @@ const spanner = new Spanner({ observabilityOptions: { tracerProvider: provider, enableExtendedTracing: true, - }, + } +}), +``` + +#### End to end tracing + +In addition to client-side tracing, you can opt in for end-to-end tracing. End-to-end tracing helps you understand and debug latency issues that are specific to Spanner. Refer [here](https://cloud.google.com/spanner/docs/tracing-overview) for more information. + +You can opt-in by either: + +* Setting the environment variable `SPANNER_ENABLE_END_TO_END_TRACING=true` before your application is started +* In code, setting `enableEndToEndTracing: true` in your SpannerOptions before creating the Cloud Spanner client + +```javascript +const spanner = new Spanner({ + projectId: projectId, + observabilityOptions: { + tracerProvider: provider, + enableEndToEndTracing: true, + } +}), ``` #### OpenTelemetry gRPC instrumentation diff --git a/README.md b/README.md index ae0b2e06c..664bfd536 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Samples are in the [`samples/`](https://github.com/googleapis/nodejs-spanner/tre | Batch | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/batch.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/batch.js,samples/README.md) | | Creates a full backup schedule | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/create-full-backup-schedule.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/create-full-backup-schedule.js,samples/README.md) | | Creates an incremental backup schedule | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/create-incremental-backup-schedule.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/create-incremental-backup-schedule.js,samples/README.md) | +| Create-instance-without-default-backup-schedules | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/create-instance-without-default-backup-schedules.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/create-instance-without-default-backup-schedules.js,samples/README.md) | | CRUD | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/crud.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/crud.js,samples/README.md) | | Creates a new database with a specific default leader | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/database-create-with-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-create-with-default-leader.js,samples/README.md) | | Database-create-with-encryption-key | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/database-create-with-encryption-key.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-create-with-encryption-key.js,samples/README.md) | @@ -206,6 +207,7 @@ Samples are in the [`samples/`](https://github.com/googleapis/nodejs-spanner/tre | Executes a read/write transaction with transaction and request tags | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/transaction-tag.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/transaction-tag.js,samples/README.md) | | Transaction | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/transaction.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/transaction.js,samples/README.md) | | Updates a backup schedule | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/update-backup-schedule.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/update-backup-schedule.js,samples/README.md) | +| Updates an instance. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/update-instance-default-backup-schedule-type.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/update-instance-default-backup-schedule-type.js,samples/README.md) | diff --git a/observability-test/database.ts b/observability-test/database.ts index 39ebe9afc..f8cc714fa 100644 --- a/observability-test/database.ts +++ b/observability-test/database.ts @@ -47,6 +47,7 @@ import {Instance, MutationGroup, Spanner} from '../src'; import * as pfy from '@google-cloud/promisify'; import {grpc} from 'google-gax'; import {MockError} from '../test/mockserver/mockspanner'; +import {FakeSessionFactory} from '../test/database'; const {generateWithAllSpansHaveDBName} = require('./helper'); const fakePfy = extend({}, pfy, { @@ -234,6 +235,7 @@ describe('Database', () => { './codec': {codec: fakeCodec}, './partial-result-stream': {partialResultStream: fakePartialResultStream}, './session-pool': {SessionPool: FakeSessionPool}, + './session-factory': {SessionFactory: FakeSessionFactory}, './session': {Session: FakeSession}, './table': {Table: FakeTable}, './transaction-runner': { diff --git a/observability-test/spanner.ts b/observability-test/spanner.ts index c60549776..5f74ca036 100644 --- a/observability-test/spanner.ts +++ b/observability-test/spanner.ts @@ -26,6 +26,7 @@ import * as mockInstanceAdmin from '../test/mockserver/mockinstanceadmin'; import * as mockDatabaseAdmin from '../test/mockserver/mockdatabaseadmin'; import * as sinon from 'sinon'; import {Row} from '../src/partial-result-stream'; +import {END_TO_END_TRACING_HEADER} from '../src/common'; const { AlwaysOnSampler, NodeTracerProvider, @@ -1930,3 +1931,59 @@ describe('Traces for ExecuteStream broken stream retries', () => { ); }); }); + +describe('End to end tracing headers', () => { + let server: grpc.Server; + let spanner: Spanner; + let spannerMock: mock.MockSpanner; + let observabilityOptions: typeof ObservabilityOptions; + + beforeEach(async () => { + observabilityOptions = { + enableEndToEndTracing: true, + }; + + const setupResult = await setup(observabilityOptions); + spanner = setupResult.spanner; + server = setupResult.server; + spannerMock = setupResult.spannerMock; + }); + + afterEach(async () => { + spannerMock.resetRequests(); + spanner.close(); + server.tryShutdown(() => {}); + }); + + it('run', done => { + const instance = spanner.instance('instance'); + const database = instance.database('database'); + database.getTransaction((err, tx) => { + assert.ifError(err); + + tx!.run('SELECT 1', async () => { + tx!.end(); + let metadataCountWithE2EHeader = 0; + let metadataCountWithTraceParent = 0; + spannerMock.getMetadata().forEach(metadata => { + if (metadata.get(END_TO_END_TRACING_HEADER)[0] !== undefined) { + metadataCountWithE2EHeader++; + assert.strictEqual( + metadata.get(END_TO_END_TRACING_HEADER)[0], + 'true' + ); + } + if (metadata.get('traceparent')[0] !== undefined) { + metadataCountWithTraceParent++; + } + }); + + // Batch Create Session request and Select 1 request. + assert.strictEqual(spannerMock.getRequests().length, 2); + assert.strictEqual(metadataCountWithE2EHeader, 2); + assert.strictEqual(metadataCountWithTraceParent, 2); + done(); + }); + }); + }); +}); diff --git a/owlbot.py b/owlbot.py index d3f2ea272..cf7c6d663 100644 --- a/owlbot.py +++ b/owlbot.py @@ -64,7 +64,7 @@ common_templates = gcp.CommonTemplates() templates = common_templates.node_library(source_location='build/src') -s.copy(templates, excludes=[".kokoro/samples-test.sh"]) +s.copy(templates, excludes=[".kokoro/samples-test.sh",".github/release-trigger.yml"]) node.postprocess_gapic_library_hermetic() diff --git a/package.json b/package.json index 3f880bd82..8ab398153 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@google-cloud/spanner", "description": "Cloud Spanner Client Library for Node.js", - "version": "7.16.0", + "version": "7.17.0", "license": "Apache-2.0", "author": "Google Inc.", "engines": { @@ -60,6 +60,7 @@ "@google-cloud/promisify": "^4.0.0", "@grpc/proto-loader": "^0.7.0", "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.27.0", "@opentelemetry/context-async-hooks": "^1.26.0", "@opentelemetry/semantic-conventions": "^1.25.1", "@types/big.js": "^6.0.0", diff --git a/protos/google/spanner/v1/spanner.proto b/protos/google/spanner/v1/spanner.proto index 847815464..d60174997 100644 --- a/protos/google/spanner/v1/spanner.proto +++ b/protos/google/spanner/v1/spanner.proto @@ -792,6 +792,17 @@ message ExecuteSqlRequest { // If the field is set to `true` but the request does not set // `partition_token`, the API returns an `INVALID_ARGUMENT` error. bool data_boost_enabled = 16; + + // Optional. If set to true, this statement marks the end of the transaction. + // The transaction should be committed or aborted after this statement + // executes, and attempts to execute any other requests against this + // transaction (including reads and queries) will be rejected. + // + // For DML statements, setting this option may cause some error reporting to + // be deferred until commit time (e.g. validation of unique constraints). + // Given this, successful execution of a DML statement should not be assumed + // until a subsequent Commit call completes successfully. + bool last_statement = 17 [(google.api.field_behavior) = OPTIONAL]; } // The request for [ExecuteBatchDml][google.spanner.v1.Spanner.ExecuteBatchDml]. @@ -861,6 +872,17 @@ message ExecuteBatchDmlRequest { // Common options for this request. RequestOptions request_options = 5; + + // Optional. If set to true, this request marks the end of the transaction. + // The transaction should be committed or aborted after these statements + // execute, and attempts to execute any other requests against this + // transaction (including reads and queries) will be rejected. + // + // Setting this option may cause some error reporting to be deferred until + // commit time (e.g. validation of unique constraints). Given this, successful + // execution of statements should not be assumed until a subsequent Commit + // call completes successfully. + bool last_statements = 6 [(google.api.field_behavior) = OPTIONAL]; } // The response for diff --git a/protos/protos.d.ts b/protos/protos.d.ts index d64ef1eb3..cd998e336 100644 --- a/protos/protos.d.ts +++ b/protos/protos.d.ts @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29515,6 +29515,9 @@ export namespace google { /** ExecuteSqlRequest dataBoostEnabled */ dataBoostEnabled?: (boolean|null); + + /** ExecuteSqlRequest lastStatement */ + lastStatement?: (boolean|null); } /** Represents an ExecuteSqlRequest. */ @@ -29565,6 +29568,9 @@ export namespace google { /** ExecuteSqlRequest dataBoostEnabled. */ public dataBoostEnabled: boolean; + /** ExecuteSqlRequest lastStatement. */ + public lastStatement: boolean; + /** * Creates a new ExecuteSqlRequest instance using the specified properties. * @param [properties] Properties to set @@ -29775,6 +29781,9 @@ export namespace google { /** ExecuteBatchDmlRequest requestOptions */ requestOptions?: (google.spanner.v1.IRequestOptions|null); + + /** ExecuteBatchDmlRequest lastStatements */ + lastStatements?: (boolean|null); } /** Represents an ExecuteBatchDmlRequest. */ @@ -29801,6 +29810,9 @@ export namespace google { /** ExecuteBatchDmlRequest requestOptions. */ public requestOptions?: (google.spanner.v1.IRequestOptions|null); + /** ExecuteBatchDmlRequest lastStatements. */ + public lastStatements: boolean; + /** * Creates a new ExecuteBatchDmlRequest instance using the specified properties. * @param [properties] Properties to set diff --git a/protos/protos.js b/protos/protos.js index 3d9b1fbe7..11a95c1c7 100644 --- a/protos/protos.js +++ b/protos/protos.js @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -75067,6 +75067,7 @@ * @property {google.spanner.v1.IRequestOptions|null} [requestOptions] ExecuteSqlRequest requestOptions * @property {google.spanner.v1.IDirectedReadOptions|null} [directedReadOptions] ExecuteSqlRequest directedReadOptions * @property {boolean|null} [dataBoostEnabled] ExecuteSqlRequest dataBoostEnabled + * @property {boolean|null} [lastStatement] ExecuteSqlRequest lastStatement */ /** @@ -75189,6 +75190,14 @@ */ ExecuteSqlRequest.prototype.dataBoostEnabled = false; + /** + * ExecuteSqlRequest lastStatement. + * @member {boolean} lastStatement + * @memberof google.spanner.v1.ExecuteSqlRequest + * @instance + */ + ExecuteSqlRequest.prototype.lastStatement = false; + /** * Creates a new ExecuteSqlRequest instance using the specified properties. * @function create @@ -75242,6 +75251,8 @@ $root.google.spanner.v1.DirectedReadOptions.encode(message.directedReadOptions, writer.uint32(/* id 15, wireType 2 =*/122).fork()).ldelim(); if (message.dataBoostEnabled != null && Object.hasOwnProperty.call(message, "dataBoostEnabled")) writer.uint32(/* id 16, wireType 0 =*/128).bool(message.dataBoostEnabled); + if (message.lastStatement != null && Object.hasOwnProperty.call(message, "lastStatement")) + writer.uint32(/* id 17, wireType 0 =*/136).bool(message.lastStatement); return writer; }; @@ -75347,6 +75358,10 @@ message.dataBoostEnabled = reader.bool(); break; } + case 17: { + message.lastStatement = reader.bool(); + break; + } default: reader.skipType(tag & 7); break; @@ -75446,6 +75461,9 @@ if (message.dataBoostEnabled != null && message.hasOwnProperty("dataBoostEnabled")) if (typeof message.dataBoostEnabled !== "boolean") return "dataBoostEnabled: boolean expected"; + if (message.lastStatement != null && message.hasOwnProperty("lastStatement")) + if (typeof message.lastStatement !== "boolean") + return "lastStatement: boolean expected"; return null; }; @@ -75549,6 +75567,8 @@ } if (object.dataBoostEnabled != null) message.dataBoostEnabled = Boolean(object.dataBoostEnabled); + if (object.lastStatement != null) + message.lastStatement = Boolean(object.lastStatement); return message; }; @@ -75596,6 +75616,7 @@ object.requestOptions = null; object.directedReadOptions = null; object.dataBoostEnabled = false; + object.lastStatement = false; } if (message.session != null && message.hasOwnProperty("session")) object.session = message.session; @@ -75630,6 +75651,8 @@ object.directedReadOptions = $root.google.spanner.v1.DirectedReadOptions.toObject(message.directedReadOptions, options); if (message.dataBoostEnabled != null && message.hasOwnProperty("dataBoostEnabled")) object.dataBoostEnabled = message.dataBoostEnabled; + if (message.lastStatement != null && message.hasOwnProperty("lastStatement")) + object.lastStatement = message.lastStatement; return object; }; @@ -75920,6 +75943,7 @@ * @property {Array.|null} [statements] ExecuteBatchDmlRequest statements * @property {number|Long|null} [seqno] ExecuteBatchDmlRequest seqno * @property {google.spanner.v1.IRequestOptions|null} [requestOptions] ExecuteBatchDmlRequest requestOptions + * @property {boolean|null} [lastStatements] ExecuteBatchDmlRequest lastStatements */ /** @@ -75978,6 +76002,14 @@ */ ExecuteBatchDmlRequest.prototype.requestOptions = null; + /** + * ExecuteBatchDmlRequest lastStatements. + * @member {boolean} lastStatements + * @memberof google.spanner.v1.ExecuteBatchDmlRequest + * @instance + */ + ExecuteBatchDmlRequest.prototype.lastStatements = false; + /** * Creates a new ExecuteBatchDmlRequest instance using the specified properties. * @function create @@ -76013,6 +76045,8 @@ writer.uint32(/* id 4, wireType 0 =*/32).int64(message.seqno); if (message.requestOptions != null && Object.hasOwnProperty.call(message, "requestOptions")) $root.google.spanner.v1.RequestOptions.encode(message.requestOptions, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim(); + if (message.lastStatements != null && Object.hasOwnProperty.call(message, "lastStatements")) + writer.uint32(/* id 6, wireType 0 =*/48).bool(message.lastStatements); return writer; }; @@ -76069,6 +76103,10 @@ message.requestOptions = $root.google.spanner.v1.RequestOptions.decode(reader, reader.uint32()); break; } + case 6: { + message.lastStatements = reader.bool(); + break; + } default: reader.skipType(tag & 7); break; @@ -76129,6 +76167,9 @@ if (error) return "requestOptions." + error; } + if (message.lastStatements != null && message.hasOwnProperty("lastStatements")) + if (typeof message.lastStatements !== "boolean") + return "lastStatements: boolean expected"; return null; }; @@ -76175,6 +76216,8 @@ throw TypeError(".google.spanner.v1.ExecuteBatchDmlRequest.requestOptions: object expected"); message.requestOptions = $root.google.spanner.v1.RequestOptions.fromObject(object.requestOptions); } + if (object.lastStatements != null) + message.lastStatements = Boolean(object.lastStatements); return message; }; @@ -76202,6 +76245,7 @@ } else object.seqno = options.longs === String ? "0" : 0; object.requestOptions = null; + object.lastStatements = false; } if (message.session != null && message.hasOwnProperty("session")) object.session = message.session; @@ -76219,6 +76263,8 @@ object.seqno = options.longs === String ? $util.Long.prototype.toString.call(message.seqno) : options.longs === Number ? new $util.LongBits(message.seqno.low >>> 0, message.seqno.high >>> 0).toNumber() : message.seqno; if (message.requestOptions != null && message.hasOwnProperty("requestOptions")) object.requestOptions = $root.google.spanner.v1.RequestOptions.toObject(message.requestOptions, options); + if (message.lastStatements != null && message.hasOwnProperty("lastStatements")) + object.lastStatements = message.lastStatements; return object; }; diff --git a/protos/protos.json b/protos/protos.json index 4920ef456..aa5a29c92 100644 --- a/protos/protos.json +++ b/protos/protos.json @@ -8065,6 +8065,13 @@ "dataBoostEnabled": { "type": "bool", "id": 16 + }, + "lastStatement": { + "type": "bool", + "id": 17, + "options": { + "(google.api.field_behavior)": "OPTIONAL" + } } }, "nested": { @@ -8126,6 +8133,13 @@ "requestOptions": { "type": "RequestOptions", "id": 5 + }, + "lastStatements": { + "type": "bool", + "id": 6, + "options": { + "(google.api.field_behavior)": "OPTIONAL" + } } }, "nested": { diff --git a/samples/README.md b/samples/README.md index 5e6612328..47b555e26 100644 --- a/samples/README.md +++ b/samples/README.md @@ -34,6 +34,7 @@ and automatic, synchronous replication for high availability. * [Batch](#batch) * [Creates a full backup schedule](#creates-a-full-backup-schedule) * [Creates an incremental backup schedule](#creates-an-incremental-backup-schedule) + * [Create-instance-without-default-backup-schedules](#create-instance-without-default-backup-schedules) * [CRUD](#crud) * [Creates a new database with a specific default leader](#creates-a-new-database-with-a-specific-default-leader) * [Database-create-with-encryption-key](#database-create-with-encryption-key) @@ -131,6 +132,7 @@ and automatic, synchronous replication for high availability. * [Executes a read/write transaction with transaction and request tags](#executes-a-read/write-transaction-with-transaction-and-request-tags) * [Transaction](#transaction) * [Updates a backup schedule](#updates-a-backup-schedule) + * [Updates an instance.](#updates-an-instance.) ## Before you begin @@ -487,6 +489,23 @@ __Usage:__ +### Create-instance-without-default-backup-schedules + +View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/create-instance-without-default-backup-schedules.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/create-instance-without-default-backup-schedules.js,samples/README.md) + +__Usage:__ + + +`node samples/create-instance-without-default-backup-schedules.js` + + +----- + + + + ### CRUD View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/crud.js). @@ -2131,6 +2150,23 @@ __Usage:__ `node update-backup-schedule.js ` +----- + + + + +### Updates an instance. + +View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/update-instance-default-backup-schedule-type.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/update-instance-default-backup-schedule-type.js,samples/README.md) + +__Usage:__ + + +`node instance-update.js ` + + diff --git a/samples/create-instance-without-default-backup-schedules.js b/samples/create-instance-without-default-backup-schedules.js new file mode 100644 index 000000000..b464f4df6 --- /dev/null +++ b/samples/create-instance-without-default-backup-schedules.js @@ -0,0 +1,73 @@ +/** + * 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. + */ + +'use strict'; + +function main(instanceId, projectId) { + async function createInstanceWithoutDefaultBackupSchedules() { + // [START spanner_create_instance_without_default_backup_schedule] + /** + * TODO(developer): Uncomment the following lines before running the sample. + **/ + // const projectId = 'my-project-id'; + // const instanceId = 'my-instance'; + + // Imports the Google Cloud client library + const {Spanner, protos} = require('@google-cloud/spanner'); + + // Creates a client + const spanner = new Spanner({ + projectId: projectId, + }); + + const instanceAdminClient = await spanner.getInstanceAdminClient(); + // Creates a new instance + try { + const [operation] = await instanceAdminClient.createInstance({ + instanceId: instanceId, + parent: instanceAdminClient.projectPath(projectId), + instance: { + config: instanceAdminClient.instanceConfigPath( + projectId, + 'regional-me-central2' + ), + nodeCount: 1, + displayName: 'Display name for the instance.', + labels: { + cloud_spanner_samples: 'true', + created: Math.round(Date.now() / 1000).toString(), // current time + }, + defaultBackupScheduleType: + protos.google.spanner.admin.instance.v1.Instance + .DefaultBackupScheduleType.NONE, + }, + }); + await operation.promise(); + + console.log( + `Created instance ${instanceId} without default backup schedules.` + ); + } catch (err) { + console.error('ERROR:', err); + } + // [END spanner_create_instance_without_default_backup_schedule] + } + createInstanceWithoutDefaultBackupSchedules(); +} +process.on('unhandledRejection', err => { + console.error(err.message); + process.exitCode = 1; +}); +main(...process.argv.slice(2)); diff --git a/samples/observability-traces.js b/samples/observability-traces.js index f8c802d4d..024223acd 100644 --- a/samples/observability-traces.js +++ b/samples/observability-traces.js @@ -76,6 +76,7 @@ async function main( observabilityOptions: { tracerProvider: provider, enableExtendedTracing: true, + enableEndToEndTracing: true, }, }); diff --git a/samples/package.json b/samples/package.json index 82383126f..141968d42 100644 --- a/samples/package.json +++ b/samples/package.json @@ -17,7 +17,7 @@ "dependencies": { "@google-cloud/kms": "^4.0.0", "@google-cloud/precise-date": "^4.0.0", - "@google-cloud/spanner": "^7.16.0", + "@google-cloud/spanner": "^7.17.0", "protobufjs": "^7.0.0", "yargs": "^17.0.0" }, diff --git a/samples/system-test/spanner.test.js b/samples/system-test/spanner.test.js index 0abf87a3a..6e589e49b 100644 --- a/samples/system-test/spanner.test.js +++ b/samples/system-test/spanner.test.js @@ -46,6 +46,10 @@ const backupsCmd = 'node backups.js'; const instanceCmd = 'node instance.js'; const createTableWithForeignKeyDeleteCascadeCommand = 'node table-create-with-foreign-key-delete-cascade.js'; +const createInstanceWithoutDefaultBackupSchedulesCommand = + 'node create-instance-without-default-backup-schedules.js'; +const updateInstanceDefaultBackupScheduleTypeCommand = + 'node update-instance-default-backup-schedule-type.js'; const alterTableWithForeignKeyDeleteCascadeCommand = 'node table-alter-with-foreign-key-delete-cascade.js'; const dropForeignKeyConstraintDeleteCascaseCommand = @@ -299,6 +303,29 @@ describe('Autogenerated Admin Clients', () => { ); }); + // create_and_update_instance_default_backup_schedule_type + it('should create an example instance without default backup schedule type and update the instance to have it', async () => { + const createInstanceOutput = execSync( + `${createInstanceWithoutDefaultBackupSchedulesCommand} "${SAMPLE_INSTANCE_ID}" ${PROJECT_ID}` + ); + assert.match( + createInstanceOutput, + new RegExp( + `Created instance ${SAMPLE_INSTANCE_ID} without default backup schedules.` + ) + ); + + const updateInstanceOutput = execSync( + `${updateInstanceDefaultBackupScheduleTypeCommand} "${SAMPLE_INSTANCE_ID}" ${PROJECT_ID}` + ); + assert.match( + updateInstanceOutput, + new RegExp( + `Instance ${SAMPLE_INSTANCE_ID} has been updated with the AUTOMATIC default backup schedule type.` + ) + ); + }); + // create_instance_with_processing_units it('should create an example instance with processing units', async () => { const output = execSync( diff --git a/samples/update-instance-default-backup-schedule-type.js b/samples/update-instance-default-backup-schedule-type.js new file mode 100644 index 000000000..89bd07cc3 --- /dev/null +++ b/samples/update-instance-default-backup-schedule-type.js @@ -0,0 +1,74 @@ +/** + * 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: Updates an instance. +// usage: node instance-update.js + +'use strict'; + +function main(instanceId, projectId) { + async function updateInstanceDefaultBackupScheduleType() { + // [START spanner_update_instance_default_backup_schedule_type] + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const projectId = 'my-project-id'; + // const instanceId = 'my-instance'; + + // Imports the Google Cloud client library + const {Spanner, protos} = require('@google-cloud/spanner'); + + // Creates a client + const spanner = new Spanner({ + projectId: projectId, + }); + const instanceAdminClient = await spanner.getInstanceAdminClient(); + + // Updates an instance + try { + const [operation] = await instanceAdminClient.updateInstance({ + instance: { + name: instanceAdminClient.instancePath(projectId, instanceId), + defaultBackupScheduleType: + protos.google.spanner.admin.instance.v1.Instance + .DefaultBackupScheduleType.AUTOMATIC, // optional + }, + // Field mask specifying fields that should get updated in an Instance + fieldMask: (protos.google.protobuf.FieldMask = { + paths: ['default_backup_schedule_type'], + }), + }); + + await operation.promise(); + const [metadata] = await instanceAdminClient.getInstance({ + name: instanceAdminClient.instancePath(projectId, instanceId), + }); + console.log( + `Instance ${instanceId} has been updated with the ${metadata.defaultBackupScheduleType}` + + ' default backup schedule type.' + ); + } catch (err) { + console.error('ERROR:', err); + } + // [END spanner_update_instance_default_backup_schedule_type] + } + updateInstanceDefaultBackupScheduleType(); +} +process.on('unhandledRejection', err => { + console.error(err.message); + process.exitCode = 1; +}); +main(...process.argv.slice(2)); diff --git a/src/common.ts b/src/common.ts index 634df1210..6eaa85984 100644 --- a/src/common.ts +++ b/src/common.ts @@ -81,6 +81,11 @@ export const CLOUD_RESOURCE_HEADER = 'google-cloud-resource-prefix'; */ export const LEADER_AWARE_ROUTING_HEADER = 'x-goog-spanner-route-to-leader'; +/* + * END TO END TRACING header. + */ +export const END_TO_END_TRACING_HEADER = 'x-goog-spanner-end-to-end-tracing'; + /** * Add Leader aware routing header to existing header list. * @param headers Existing header list. @@ -88,3 +93,25 @@ export const LEADER_AWARE_ROUTING_HEADER = 'x-goog-spanner-route-to-leader'; export function addLeaderAwareRoutingHeader(headers: {[k: string]: string}) { headers[LEADER_AWARE_ROUTING_HEADER] = 'true'; } + +/** + * Returns common headers to add. + * @param headers Common header list. + */ +export function getCommonHeaders( + resourceName: string, + enableTracing?: boolean +) { + const headers: {[k: string]: string} = {}; + + if ( + process.env.SPANNER_ENABLE_END_TO_END_TRACING === 'true' || + enableTracing + ) { + headers[END_TO_END_TRACING_HEADER] = 'true'; + } + + headers[CLOUD_RESOURCE_HEADER] = resourceName; + + return headers; +} diff --git a/src/database.ts b/src/database.ts index 2744015be..edccf23c1 100644 --- a/src/database.ts +++ b/src/database.ts @@ -36,6 +36,7 @@ import { } from 'google-gax'; import {Backup} from './backup'; import {BatchTransaction, TransactionIdentifier} from './batch-transaction'; +import {SessionFactory, SessionFactoryInterface} from './session-factory'; import { google as databaseAdmin, google, @@ -85,12 +86,12 @@ import { LongRunningCallback, NormalCallback, PagedOptionsWithFilter, - CLOUD_RESOURCE_HEADER, PagedResponse, RequestCallback, ResourceCallback, Schema, addLeaderAwareRoutingHeader, + getCommonHeaders, } from './common'; import {finished, Duplex, Readable, Transform} from 'stream'; import {PreciseDate} from '@google-cloud/precise-date'; @@ -111,7 +112,6 @@ import { setSpanErrorAndException, traceConfig, } from './instrument'; - export type GetDatabaseRolesCallback = RequestCallback< IDatabaseRole, databaseAdmin.spanner.admin.database.v1.IListDatabaseRolesResponse @@ -339,10 +339,12 @@ class Database extends common.GrpcServiceObject { private instance: Instance; formattedName_: string; pool_: SessionPoolInterface; + sessionFactory_: SessionFactoryInterface; queryOptions_?: spannerClient.spanner.v1.ExecuteSqlRequest.IQueryOptions; - resourceHeader_: {[k: string]: string}; + commonHeaders_: {[k: string]: string}; request: DatabaseRequest; databaseRole?: string | null; + labels?: {[k: string]: string} | null; databaseDialect?: EnumKey< typeof databaseAdmin.spanner.admin.database.v1.DatabaseDialect > | null; @@ -449,17 +451,9 @@ class Database extends common.GrpcServiceObject { }, } as {} as ServiceObjectConfig); - this.pool_ = - typeof poolOptions === 'function' - ? new (poolOptions as SessionPoolConstructor)(this, null) - : new SessionPool(this, poolOptions); - const sessionPoolInstance = this.pool_ as SessionPool; - if (sessionPoolInstance) { - sessionPoolInstance._observabilityOptions = - instance._observabilityOptions; - } if (typeof poolOptions === 'object') { this.databaseRole = poolOptions.databaseRole || null; + this.labels = poolOptions.labels || null; } this.formattedName_ = formattedName_; this.instance = instance; @@ -469,15 +463,22 @@ class Database extends common.GrpcServiceObject { dbName: this.formattedName_, }; - this.resourceHeader_ = { - [CLOUD_RESOURCE_HEADER]: this.formattedName_, - }; this.request = instance.request; this._observabilityOptions = instance._observabilityOptions; + this.commonHeaders_ = getCommonHeaders( + this.formattedName_, + this._observabilityOptions?.enableEndToEndTracing + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any this.requestStream = instance.requestStream as any; - this.pool_.on('error', this.emit.bind(this, 'error')); - this.pool_.open(); + this.sessionFactory_ = new SessionFactory(this, name, poolOptions); + this.pool_ = this.sessionFactory_.getPool(); + const sessionPoolInstance = this.pool_ as SessionPool; + if (sessionPoolInstance) { + sessionPoolInstance._observabilityOptions = + instance._observabilityOptions; + } this.queryOptions_ = Object.assign( Object.assign({}, queryOptions), Database.getEnvironmentQueryOptions() @@ -580,7 +581,7 @@ class Database extends common.GrpcServiceObject { method: 'updateDatabase', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, callback! ); @@ -686,7 +687,7 @@ class Database extends common.GrpcServiceObject { sessionCount: count, }; - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if (this._getSpanner().routeToLeaderEnabled) { addLeaderAwareRoutingHeader(headers); } @@ -978,40 +979,43 @@ class Database extends common.GrpcServiceObject { reqOpts.session = {}; - if (options.labels) { - reqOpts.session.labels = options.labels; - } - if (options.multiplexed) { reqOpts.session.multiplexed = options.multiplexed; } + reqOpts.session.labels = options.labels || this.labels || null; + reqOpts.session.creatorRole = options.databaseRole || this.databaseRole || null; - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if (this._getSpanner().routeToLeaderEnabled) { addLeaderAwareRoutingHeader(headers); } - this.request( - { - client: 'SpannerClient', - method: 'createSession', - reqOpts, - gaxOpts: options.gaxOptions, - headers: headers, - }, - (err, resp) => { - if (err) { - callback(err, null, resp!); - return; + startTrace('Database.createSession', this._traceConfig, span => { + this.request( + { + client: 'SpannerClient', + method: 'createSession', + reqOpts, + gaxOpts: options.gaxOptions, + headers: headers, + }, + (err, resp) => { + if (err) { + setSpanError(span, err); + span.end(); + callback(err, null, resp!); + return; + } + const session = this.session(resp!.name!); + session.metadata = resp; + span.end(); + callback(null, session, resp!); } - const session = this.session(resp!.name!); - session.metadata = resp; - callback(null, session, resp!); - } - ); + ); + }); } /** * @typedef {array} CreateTableResponse @@ -1210,7 +1214,7 @@ class Database extends common.GrpcServiceObject { method: 'dropDatabase', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, callback! ); @@ -1442,7 +1446,7 @@ class Database extends common.GrpcServiceObject { method: 'getDatabase', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, resp) => { if (resp) { @@ -1696,7 +1700,7 @@ class Database extends common.GrpcServiceObject { method: 'getDatabaseDdl', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any (err, statements, ...args: any[]) => { @@ -1776,7 +1780,7 @@ class Database extends common.GrpcServiceObject { method: 'getIamPolicy', reqOpts, gaxOpts: options.gaxOptions, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, resp) => { callback!(err, resp); @@ -1915,7 +1919,7 @@ class Database extends common.GrpcServiceObject { method: 'listSessions', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, sessions, nextPageRequest, ...args) => { if (err) { @@ -2008,7 +2012,7 @@ class Database extends common.GrpcServiceObject { method: 'listSessionsStream', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }); } @@ -2087,6 +2091,26 @@ class Database extends common.GrpcServiceObject { ? (optionsOrCallback as TimestampBounds) : {}; + if ( + ('maxStaleness' in options && + options.maxStaleness !== null && + options.maxStaleness !== undefined) || + ('minReadTimestamp' in options && + options.minReadTimestamp !== null && + options.minReadTimestamp !== undefined) + ) { + const error = Object.assign( + new Error( + 'maxStaleness / minReadTimestamp is not supported for multi-use read-only transactions.' + ), + { + code: 3, // invalid argument + } + ) as ServiceError; + callback!(error); + return; + } + return startTrace('Database.getSnapshot', this._traceConfig, span => { this.pool_.getSession((err, session) => { if (err) { @@ -2204,11 +2228,6 @@ class Database extends common.GrpcServiceObject { span.addEvent('Using Session', {'session.id': session?.id}); transaction!._observabilityOptions = this._observabilityOptions; this._releaseOnEnd(session!, transaction!, span); - } else if (isSessionNotFoundError(err as grpc.ServiceError)) { - span.addEvent('No session available', { - 'session.id': session?.id, - }); - setSpanError(span, err); } else { setSpanError(span, err); } @@ -2405,7 +2424,7 @@ class Database extends common.GrpcServiceObject { method: 'listDatabaseRoles', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, roles, nextPageRequest, ...args) => { const nextQuery = nextPageRequest! @@ -2486,11 +2505,7 @@ class Database extends common.GrpcServiceObject { pool.getSession((err, session_) => { const span = getActiveOrNoopSpan(); if (err) { - if (isSessionNotFoundError(err as grpc.ServiceError)) { - span.addEvent('No session available', { - 'session.id': session?.id, - }); - } + setSpanError(span, err as ServiceError); destroyStream(err as ServiceError); return; } @@ -2619,7 +2634,7 @@ class Database extends common.GrpcServiceObject { method: 'restoreDatabase', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, operation, resp) => { if (err) { @@ -3411,10 +3426,11 @@ class Database extends common.GrpcServiceObject { this.pool_.release(session); } } catch (e) { - if (!isSessionNotFoundError(e as ServiceError)) { + if (isSessionNotFoundError(e as ServiceError)) { span.addEvent('No session available', { 'session.id': sessionId, }); + } else { span.end(); throw e; } @@ -3517,7 +3533,7 @@ class Database extends common.GrpcServiceObject { method: 'batchWrite', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }); dataStream .once('data', () => (dataReceived = true)) @@ -3799,7 +3815,7 @@ class Database extends common.GrpcServiceObject { method: 'setIamPolicy', reqOpts, gaxOpts: gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, resp) => { callback!(err, resp); @@ -3929,7 +3945,7 @@ class Database extends common.GrpcServiceObject { method: 'updateDatabaseDdl', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, callback! ); diff --git a/src/helper.ts b/src/helper.ts new file mode 100644 index 000000000..52e88b7a1 --- /dev/null +++ b/src/helper.ts @@ -0,0 +1,90 @@ +/** + * 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. + */ + +import {grpc} from 'google-gax'; +/** + * Checks whether the given error is a 'Database not found' error. + * @param {Error} error The error to check. + * @return {boolean} True if the error is a 'Database not found' error, and otherwise false. + */ +export function isDatabaseNotFoundError( + error: grpc.ServiceError | undefined +): boolean { + return ( + error !== undefined && + error.code === grpc.status.NOT_FOUND && + error.message.includes('Database not found') + ); +} + +/** + * Checks whether the given error is an 'Instance not found' error. + * @param {Error} error The error to check. + * @return {boolean} True if the error is an 'Instance not found' error, and otherwise false. + */ +export function isInstanceNotFoundError( + error: grpc.ServiceError | undefined +): boolean { + return ( + error !== undefined && + error.code === grpc.status.NOT_FOUND && + error.message.includes('Instance not found') + ); +} + +/** + * Checks whether the given error is a 'Could not load the default credentials' error. + * @param {Error} error The error to check. + * @return {boolean} True if the error is a 'Could not load the default credentials' error, and otherwise false. + */ +export function isDefaultCredentialsNotSetError( + error: grpc.ServiceError | undefined +): boolean { + return ( + error !== undefined && + error.message.includes('Could not load the default credentials') + ); +} + +/** + * Checks whether the given error is an 'Unable to detect a Project Id in the current environment' error. + * @param {Error} error The error to check. + * @return {boolean} True if the error is an 'Unable to detect a Project Id in the current environment' error, and otherwise false. + */ +export function isProjectIdNotSetInEnvironmentError( + error: grpc.ServiceError | undefined +): boolean { + return ( + error !== undefined && + error.message.includes( + 'Unable to detect a Project Id in the current environment' + ) + ); +} + +/** + * Checks whether the given error is a 'Create session permission' error. + * @param {Error} error The error to check. + * @return {boolean} True if the error is a 'Create session permission' error, and otherwise false. + */ +export function isCreateSessionPermissionError( + error: grpc.ServiceError | undefined +): boolean { + return ( + error !== undefined && + error.code === grpc.status.PERMISSION_DENIED && + error.message.includes('spanner.sessions.create') + ); +} diff --git a/src/index.ts b/src/index.ts index 0bbdccd01..f0f88a801 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ import { IProtoMessageParams, IProtoEnumParams, } from './codec'; +import {context, propagation} from '@opentelemetry/api'; import {Backup} from './backup'; import {Database} from './database'; import { @@ -66,6 +67,7 @@ import { PagedOptionsWithFilter, CLOUD_RESOURCE_HEADER, NormalCallback, + getCommonHeaders, } from './common'; import {Session} from './session'; import {SessionPool} from './session-pool'; @@ -83,6 +85,7 @@ import * as v1 from './v1'; import { ObservabilityOptions, ensureInitialContextManagerSet, + ensureContextPropagation, } from './instrument'; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -242,7 +245,7 @@ class Spanner extends GrpcService { instanceConfigs_: Map; projectIdReplaced_: boolean; projectFormattedName_: string; - resourceHeader_: {[k: string]: string}; + commonHeaders_: {[k: string]: string}; routeToLeaderEnabled = true; directedReadOptions: google.spanner.v1.IDirectedReadOptions | null; _observabilityOptions: ObservabilityOptions | undefined; @@ -368,12 +371,14 @@ class Spanner extends GrpcService { this.instanceConfigs_ = new Map(); this.projectIdReplaced_ = false; this.projectFormattedName_ = 'projects/' + this.projectId; - this.resourceHeader_ = { - [CLOUD_RESOURCE_HEADER]: this.projectFormattedName_, - }; this.directedReadOptions = directedReadOptions; this._observabilityOptions = options.observabilityOptions; + this.commonHeaders_ = getCommonHeaders( + this.projectFormattedName_, + this._observabilityOptions?.enableEndToEndTracing + ); ensureInitialContextManagerSet(); + ensureContextPropagation(); } /** @@ -586,7 +591,7 @@ class Spanner extends GrpcService { method: 'createInstance', reqOpts, gaxOpts: config.gaxOptions, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, operation, resp) => { if (err) { @@ -732,7 +737,7 @@ class Spanner extends GrpcService { method: 'listInstances', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, instances, nextPageRequest, ...args) => { let instanceInstances: Instance[] | null = null; @@ -816,7 +821,7 @@ class Spanner extends GrpcService { method: 'listInstancesStream', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }); } @@ -982,7 +987,7 @@ class Spanner extends GrpcService { method: 'createInstanceConfig', reqOpts, gaxOpts: config.gaxOptions, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, operation, resp) => { if (err) { @@ -1125,7 +1130,7 @@ class Spanner extends GrpcService { method: 'listInstanceConfigs', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, instanceConfigs, nextPageRequest, ...args) => { const nextQuery = nextPageRequest! @@ -1201,7 +1206,7 @@ class Spanner extends GrpcService { method: 'listInstanceConfigsStream', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }); } @@ -1296,7 +1301,7 @@ class Spanner extends GrpcService { method: 'getInstanceConfig', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, instanceConfig) => { callback!(err, instanceConfig); @@ -1416,7 +1421,7 @@ class Spanner extends GrpcService { method: 'listInstanceConfigOperations', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, operations, nextPageRequest, ...args) => { const nextQuery = nextPageRequest! @@ -1532,6 +1537,12 @@ class Spanner extends GrpcService { config.headers[CLOUD_RESOURCE_HEADER], projectId! ); + // Do context propagation + propagation.inject(context.active(), config.headers, { + set: (carrier, key, value) => { + carrier[key] = value; // Set the span context (trace and span ID) + }, + }); const requestFn = gaxClient[config.method].bind( gaxClient, reqOpts, diff --git a/src/instance.ts b/src/instance.ts index 4986e3ecd..72257b24c 100644 --- a/src/instance.ts +++ b/src/instance.ts @@ -31,7 +31,7 @@ import { NormalCallback, ResourceCallback, PagedOptionsWithFilter, - CLOUD_RESOURCE_HEADER, + getCommonHeaders, } from './common'; import {Duplex} from 'stream'; import {SessionPoolOptions, SessionPool} from './session-pool'; @@ -164,7 +164,7 @@ class Instance extends common.GrpcServiceObject { requestStream: (config: RequestConfig) => Duplex; databases_: Map; metadata?: IInstance; - resourceHeader_: {[k: string]: string}; + commonHeaders_: {[k: string]: string}; _observabilityOptions?: ObservabilityOptions; constructor(spanner: Spanner, name: string) { const formattedName_ = Instance.formatName_(spanner.projectId, name); @@ -238,10 +238,11 @@ class Instance extends common.GrpcServiceObject { this.request = spanner.request.bind(spanner); this.requestStream = spanner.requestStream.bind(spanner); this.databases_ = new Map(); - this.resourceHeader_ = { - [CLOUD_RESOURCE_HEADER]: this.formattedName_, - }; this._observabilityOptions = spanner._observabilityOptions; + this.commonHeaders_ = getCommonHeaders( + this.formattedName_, + this._observabilityOptions?.enableEndToEndTracing + ); } /** @@ -432,7 +433,7 @@ class Instance extends common.GrpcServiceObject { method: 'listBackups', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, backups, nextPageRequest, ...args) => { let backupInstances: Backup[] | null = null; @@ -519,7 +520,7 @@ class Instance extends common.GrpcServiceObject { method: 'listBackupsStream', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }); } @@ -638,7 +639,7 @@ class Instance extends common.GrpcServiceObject { method: 'listBackupOperations', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, operations, nextPageRequest, ...args) => { const nextQuery = nextPageRequest! @@ -766,7 +767,7 @@ class Instance extends common.GrpcServiceObject { method: 'listDatabaseOperations', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, operations, nextPageRequest, ...args) => { const nextQuery = nextPageRequest! @@ -920,7 +921,7 @@ class Instance extends common.GrpcServiceObject { method: 'createDatabase', reqOpts, gaxOpts: options.gaxOptions, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, operation, resp) => { if (err) { @@ -1059,7 +1060,7 @@ class Instance extends common.GrpcServiceObject { method: 'deleteInstance', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, resp) => { if (!err) { @@ -1355,7 +1356,7 @@ class Instance extends common.GrpcServiceObject { method: 'listDatabases', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, rowDatabases, nextPageRequest, ...args) => { let databases: Database[] | null = null; @@ -1443,7 +1444,7 @@ class Instance extends common.GrpcServiceObject { method: 'listDatabasesStream', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }); } @@ -1540,7 +1541,7 @@ class Instance extends common.GrpcServiceObject { method: 'getInstance', reqOpts, gaxOpts: options.gaxOptions, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, (err, resp) => { if (resp) { @@ -1636,7 +1637,7 @@ class Instance extends common.GrpcServiceObject { method: 'updateInstance', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, callback! ); diff --git a/src/instrument.ts b/src/instrument.ts index 8ad123bf6..2c7998733 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -27,6 +27,7 @@ import { Span, SpanStatusCode, context, + propagation, trace, INVALID_SPAN_CONTEXT, ROOT_CONTEXT, @@ -59,6 +60,7 @@ interface SQLStatement { interface ObservabilityOptions { tracerProvider: TracerProvider; enableExtendedTracing?: boolean; + enableEndToEndTracing?: boolean; } export type {ObservabilityOptions}; @@ -98,6 +100,8 @@ const { AsyncHooksContextManager, } = require('@opentelemetry/context-async-hooks'); +const {W3CTraceContextPropagator} = require('@opentelemetry/core'); + /* * This function ensures that async/await works correctly by * checking if context.active() returns an invalid/unset context @@ -116,8 +120,15 @@ function ensureInitialContextManagerSet() { context.setGlobalContextManager(contextManager); } } + +function ensureContextPropagation() { + propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +} + export {ensureInitialContextManagerSet}; +export {ensureContextPropagation}; + /** * startTrace begins an active span in the current active context * and passes it back to the set callback function. Each span will diff --git a/src/multiplexed-session.ts b/src/multiplexed-session.ts new file mode 100644 index 000000000..77f1cbb22 --- /dev/null +++ b/src/multiplexed-session.ts @@ -0,0 +1,263 @@ +/*! + * Copyright 2024 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 {EventEmitter} from 'events'; +import {Database} from './database'; +import {Session} from './session'; +import {GetSessionCallback} from './session-factory'; +import { + ObservabilityOptions, + getActiveOrNoopSpan, + setSpanError, + startTrace, +} from './instrument'; + +export const MUX_SESSION_AVAILABLE = 'mux-session-available'; +export const MUX_SESSION_CREATE_ERROR = 'mux-session-create-error'; + +/** + * Interface for implementing multiplexed session logic, it should extend the + * {@link https://nodejs.org/api/events.html|EventEmitter} class + * + * @interface MultiplexedSessionInterface + * @extends external:{@link https://nodejs.org/api/events.html|EventEmitter} + * + * @constructs MultiplexedSessionInterface + * @param {Database} database The database to create a multiplexed session for. + */ +export interface MultiplexedSessionInterface extends EventEmitter { + /** + * When called creates a multiplexed session. + * + * @name MultiplexedSessionInterface#createSession + */ + createSession(): void; + + /** + * When called returns a multiplexed session. + * + * @name MultiplexedSessionInterface#getSession + * @param {GetSessionCallback} callback The callback function. + */ + getSession(callback: GetSessionCallback): void; +} + +/** + * Class used to manage connections to Spanner using multiplexed session. + * + * **You don't need to use this class directly, connections will be handled for + * you.** + * + * @class + * @extends {EventEmitter} + */ +export class MultiplexedSession + extends EventEmitter + implements MultiplexedSessionInterface +{ + database: Database; + // frequency to create new mux session + refreshRate: number; + isMultiplexedEnabled: boolean; + _multiplexedSession: Session | null; + _refreshHandle!: NodeJS.Timer; + _observabilityOptions?: ObservabilityOptions; + constructor(database: Database) { + super(); + this.database = database; + // default frequency is 7 days + this.refreshRate = 7; + this._multiplexedSession = null; + this._observabilityOptions = database._observabilityOptions; + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS === 'true' + ? (this.isMultiplexedEnabled = true) + : (this.isMultiplexedEnabled = false); + } + + /** + * Creates a new multiplexed session and manages its maintenance. + * + * This method initiates the session creation process by calling the `_createSession` method, which returns a Promise. + */ + createSession(): void { + this._createSession() + .then(() => { + this._maintain(); + }) + .catch(err => { + this.emit('error', err); + }); + } + + /** + * Creates a new multiplexed session. + * + * This method sends a request to the database to create a new session with multiplexing enabled. + * The response from the database would be an array, the first value of the array will be containing the multiplexed session. + * + * @returns {Promise} A Promise that resolves when the session has been successfully created and assigned, an event + * `mux-session-available` will be emitted to signal that the session is ready. + * + * In case of error, an error will get emitted along with the error event. + * + * @private + */ + async _createSession(): Promise { + const traceConfig = { + opts: this._observabilityOptions, + dbName: this.database.formattedName_, + }; + return startTrace( + 'MultiplexedSession.createSession', + traceConfig, + async span => { + span.addEvent('Requesting a multiplexed session'); + try { + const [createSessionResponse] = await this.database.createSession({ + multiplexed: true, + }); + this._multiplexedSession = createSessionResponse; + span.addEvent( + `Created multiplexed session ${this._multiplexedSession.id}` + ); + this.emit(MUX_SESSION_AVAILABLE); + } catch (e) { + setSpanError(span, e as Error); + this.emit(MUX_SESSION_CREATE_ERROR, e); + throw e; + } finally { + span.end(); + } + } + ); + } + + /** + * Maintains the multiplexed session by periodically refreshing it. + * + * This method sets up a periodic refresh interval for maintaining the session. The interval duration + * is determined by the @param refreshRate option, which is provided in days. + * The default value is 7 days. + * + * @throws {Error} If the multiplexed session creation fails in `_createSession`, the error is caught + * and ignored. This is because the currently active multiplexed session has a 30-day expiry, providing + * the maintainer with four opportunities (one every 7 days) to refresh the active session. + * + * @returns {void} This method does not return any value. + * + */ + _maintain(): void { + const refreshRate = this.refreshRate! * 24 * 60 * 60000; + this._refreshHandle = setInterval(async () => { + try { + await this._createSession(); + } catch (err) { + return; + } + }, refreshRate); + this._refreshHandle.unref(); + } + + /** + * Retrieves a session asynchronously and invokes a callback with the session details. + * + * @param {GetSessionCallback} callback - The callback to be invoked once the session is acquired or an error occurs. + * + * @returns {void} This method does not return any value, as it operates asynchronously and relies on the callback. + * + */ + getSession(callback: GetSessionCallback): void { + this._acquire().then( + session => callback(null, session, session?.txn), + callback + ); + } + + /** + * Acquires a session asynchronously, and prepares the transaction for the session. + * + * Once a session is successfully acquired, it returns the session object (which may be `null` if unsuccessful). + * + * @returns {Promise} + * A Promise that resolves with the acquired session (or `null` if no session is available after retries). + * + */ + async _acquire(): Promise { + const session = await this._getSession(); + // Prepare a transaction for a session + session!.txn = session!.transaction( + (session!.parent as Database).queryOptions_ + ); + return session; + } + + /** + * Attempts to get a session, waiting for it to become available if necessary. + * + * Waits for the `MUX_SESSION_AVAILABLE` event or for the `MUX_SESSION_CREATE_ERROR` + * to be emitted if the multiplexed session is not yet available. The method listens + * for these events, and once `mux-session-available` is emitted, it resolves and returns + * the session. + * + * In case of an error, the promise will get rejected and the error will get bubble up to the parent method. + * + * @returns {Promise} A promise that resolves with the current multiplexed session if available, + * or `null` if the session is not available. + * + * @private + * + */ + async _getSession(): Promise { + const span = getActiveOrNoopSpan(); + // Check if the multiplexed session is already available + if (this._multiplexedSession !== null) { + span.addEvent('Cache hit: has usable multiplexed session'); + return this._multiplexedSession; + } + + // Define event and promises to wait for the session to become available or for the error + span.addEvent('Waiting for a multiplexed session to become available'); + let removeAvailableListener: Function; + let removeErrorListener: Function; + const promises = [ + new Promise((_, reject) => { + this.once(MUX_SESSION_CREATE_ERROR, reject); + removeErrorListener = this.removeListener.bind( + this, + MUX_SESSION_CREATE_ERROR, + reject + ); + }), + new Promise(resolve => { + this.once(MUX_SESSION_AVAILABLE, resolve); + removeAvailableListener = this.removeListener.bind( + this, + MUX_SESSION_AVAILABLE, + resolve + ); + }), + ]; + + try { + await Promise.race(promises); + } finally { + removeAvailableListener!(); + removeErrorListener!(); + } + // Return the multiplexed session after it becomes available + return this._multiplexedSession; + } +} diff --git a/src/session-factory.ts b/src/session-factory.ts new file mode 100644 index 000000000..a047dff0d --- /dev/null +++ b/src/session-factory.ts @@ -0,0 +1,161 @@ +/*! + * Copyright 2024 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 {Database, Session, Transaction} from '.'; +import { + MultiplexedSession, + MultiplexedSessionInterface, +} from './multiplexed-session'; +import { + SessionPool, + SessionPoolInterface, + SessionPoolOptions, +} from './session-pool'; +import {SessionPoolConstructor} from './database'; +import {ServiceObjectConfig} from '@google-cloud/common'; +const common = require('./common-grpc/service-object'); + +/** + * @callback GetSessionCallback + * @param {?Error} error Request error, if any. + * @param {Session} session The read-write session. + * @param {Transaction} transaction The transaction object. + */ +export interface GetSessionCallback { + ( + err: Error | null, + session?: Session | null, + transaction?: Transaction | null + ): void; +} + +/** + * Interface for implementing session-factory logic. + * + * @interface SessionFactoryInterface + */ +export interface SessionFactoryInterface { + /** + * When called returns a session. + * + * @name SessionFactoryInterface#getSession + * @param {GetSessionCallback} callback The callback function. + */ + getSession(callback: GetSessionCallback): void; + + /** + * When called returns the pool object. + * + * @name SessionFactoryInterface#getPool + */ + getPool(): SessionPoolInterface; + + /** + * To be called when releasing a session. + * + * @name SessionFactoryInterface#release + * @param {Session} session The session to be released. + */ + release(session: Session): void; +} + +/** + * Creates a SessionFactory object to manage the creation of + * session-pool and multiplexed session. + * + * @class + * + * @param {Database} database Database object. + * @param {String} name Name of the database. + * @param {SessionPoolOptions|SessionPoolInterface} options Session pool + * configuration options or custom pool inteface. + */ +export class SessionFactory + extends common.GrpcServiceObject + implements SessionFactoryInterface +{ + multiplexedSession_: MultiplexedSessionInterface; + pool_: SessionPoolInterface; + constructor( + database: Database, + name: String, + poolOptions?: SessionPoolConstructor | SessionPoolOptions + ) { + super({ + parent: database, + id: name, + } as {} as ServiceObjectConfig); + this.pool_ = + typeof poolOptions === 'function' + ? new (poolOptions as SessionPoolConstructor)(database, null) + : new SessionPool(database, poolOptions); + this.pool_.on('error', this.emit.bind(database, 'error')); + this.pool_.open(); + this.multiplexedSession_ = new MultiplexedSession(database); + // Multiplexed sessions should only be created if its enabled. + if ((this.multiplexedSession_ as MultiplexedSession).isMultiplexedEnabled) { + this.multiplexedSession_.on('error', this.emit.bind(database, 'error')); + this.multiplexedSession_.createSession(); + } + } + + /** + * Retrieves a session, either a regular session or a multiplexed session, based on the environment variable configuration. + * + * If the environment variable `GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS` is set to `true`, the method will attempt to + * retrieve a multiplexed session. Otherwise, it will retrieve a session from the regular pool. + * + * @param {GetSessionCallback} callback The callback function. + */ + + getSession(callback: GetSessionCallback): void { + const sessionHandler = (this.multiplexedSession_ as MultiplexedSession) + .isMultiplexedEnabled + ? this.multiplexedSession_ + : this.pool_; + + sessionHandler!.getSession((err, session) => callback(err, session)); + } + + /** + * Returns the regular session pool object. + * + * @returns {SessionPoolInterface} The session pool used by current instance. + */ + + getPool(): SessionPoolInterface { + return this.pool_; + } + + /** + * Releases a session back to the session pool. + * + * This method returns a session to the pool after it is no longer needed. + * It is a no-op for multiplexed sessions. + * + * @param {Session} session - The session to be released. This should be an instance of `Session` that was + * previously acquired from the session pool. + * + * @throws {Error} If the session is invalid or cannot be released. + */ + release(session: Session): void { + if ( + !(this.multiplexedSession_ as MultiplexedSession).isMultiplexedEnabled + ) { + this.pool_.release(session); + } + } +} diff --git a/src/session-pool.ts b/src/session-pool.ts index b206bcf8e..025216937 100644 --- a/src/session-pool.ts +++ b/src/session-pool.ts @@ -30,6 +30,14 @@ import { setSpanErrorAndException, startTrace, } from './instrument'; +import {GetSessionCallback} from './session-factory'; +import { + isDatabaseNotFoundError, + isInstanceNotFoundError, + isDefaultCredentialsNotSetError, + isProjectIdNotSetInEnvironmentError, + isCreateSessionPermissionError, +} from './helper'; /** * @callback SessionPoolCloseCallback @@ -51,20 +59,6 @@ export interface GetWriteSessionCallback { ): void; } -/** - * @callback GetSessionCallback - * @param {?Error} error Request error, if any. - * @param {Session} session The read-write session. - * @param {Transaction} transaction The transaction object. - */ -export interface GetSessionCallback { - ( - err: Error | null, - session?: Session | null, - transaction?: Transaction | null - ): void; -} - /** * Interface for implementing custom session pooling logic, it should extend the * {@link https://nodejs.org/api/events.html|EventEmitter} class and emit any @@ -241,81 +235,6 @@ export function isSessionNotFoundError( ); } -/** - * Checks whether the given error is a 'Database not found' error. - * @param {Error} error The error to check. - * @return {boolean} True if the error is a 'Database not found' error, and otherwise false. - */ -export function isDatabaseNotFoundError( - error: grpc.ServiceError | undefined -): boolean { - return ( - error !== undefined && - error.code === grpc.status.NOT_FOUND && - error.message.includes('Database not found') - ); -} - -/** - * Checks whether the given error is an 'Instance not found' error. - * @param {Error} error The error to check. - * @return {boolean} True if the error is an 'Instance not found' error, and otherwise false. - */ -export function isInstanceNotFoundError( - error: grpc.ServiceError | undefined -): boolean { - return ( - error !== undefined && - error.code === grpc.status.NOT_FOUND && - error.message.includes('Instance not found') - ); -} - -/** - * Checks whether the given error is a 'Create session permission' error. - * @param {Error} error The error to check. - * @return {boolean} True if the error is a 'Create session permission' error, and otherwise false. - */ -export function isCreateSessionPermissionError( - error: grpc.ServiceError | undefined -): boolean { - return ( - error !== undefined && - error.code === grpc.status.PERMISSION_DENIED && - error.message.includes('spanner.sessions.create') - ); -} - -/** - * Checks whether the given error is a 'Could not load the default credentials' error. - * @param {Error} error The error to check. - * @return {boolean} True if the error is a 'Could not load the default credentials' error, and otherwise false. - */ -export function isDefaultCredentialsNotSetError( - error: grpc.ServiceError | undefined -): boolean { - return ( - error !== undefined && - error.message.includes('Could not load the default credentials') - ); -} - -/** - * Checks whether the given error is an 'Unable to detect a Project Id in the current environment' error. - * @param {Error} error The error to check. - * @return {boolean} True if the error is an 'Unable to detect a Project Id in the current environment' error, and otherwise false. - */ -export function isProjectIdNotSetInEnvironmentError( - error: grpc.ServiceError | undefined -): boolean { - return ( - error !== undefined && - error.message.includes( - 'Unable to detect a Project Id in the current environment' - ) - ); -} - /** * enum to capture errors that can appear from multiple places */ diff --git a/src/session.ts b/src/session.ts index 2ece6b45d..32d79d352 100644 --- a/src/session.ts +++ b/src/session.ts @@ -38,8 +38,8 @@ import { import {ServiceObjectConfig} from '@google-cloud/common'; import { NormalCallback, - CLOUD_RESOURCE_HEADER, addLeaderAwareRoutingHeader, + getCommonHeaders, } from './common'; import {grpc, CallOptions} from 'google-gax'; import IRequestOptions = google.spanner.v1.IRequestOptions; @@ -117,7 +117,7 @@ export class Session extends common.GrpcServiceObject { txn?: Transaction; lastUsed?: number; lastError?: grpc.ServiceError; - resourceHeader_: {[k: string]: string}; + commonHeaders_: {[k: string]: string}; constructor(database: Database, name?: string) { const methods = { /** @@ -259,9 +259,10 @@ export class Session extends common.GrpcServiceObject { }, } as {} as ServiceObjectConfig); - this.resourceHeader_ = { - [CLOUD_RESOURCE_HEADER]: (this.parent as Database).formattedName_, - }; + this.commonHeaders_ = getCommonHeaders( + (this.parent as Database).formattedName_, + database._observabilityOptions?.enableEndToEndTracing + ); this.request = database.request; this.requestStream = database.requestStream; @@ -322,7 +323,7 @@ export class Session extends common.GrpcServiceObject { method: 'deleteSession', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, callback! ); @@ -384,7 +385,7 @@ export class Session extends common.GrpcServiceObject { name: this.formattedName_, }; - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if (this._getSpanner().routeToLeaderEnabled) { addLeaderAwareRoutingHeader(headers); } @@ -446,7 +447,7 @@ export class Session extends common.GrpcServiceObject { method: 'executeSql', reqOpts, gaxOpts, - headers: this.resourceHeader_, + headers: this.commonHeaders_, }, callback! ); diff --git a/src/transaction.ts b/src/transaction.ts index fa1f10814..725ec3235 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -37,8 +37,8 @@ import {getActiveOrNoopSpan} from './instrument'; import {google as spannerClient} from '../protos/protos'; import { NormalCallback, - CLOUD_RESOURCE_HEADER, addLeaderAwareRoutingHeader, + getCommonHeaders, } from './common'; import {google} from '../protos/protos'; import IAny = google.protobuf.IAny; @@ -289,7 +289,7 @@ export class Snapshot extends EventEmitter { requestStream: (config: {}) => Readable; session: Session; queryOptions?: IQueryOptions; - resourceHeader_: {[k: string]: string}; + commonHeaders_: {[k: string]: string}; requestOptions?: Pick; _observabilityOptions?: ObservabilityOptions; protected _dbName?: string; @@ -353,12 +353,13 @@ export class Snapshot extends EventEmitter { const readOnly = Snapshot.encodeTimestampBounds(options || {}); this._options = {readOnly}; this._dbName = (this.session.parent as Database).formattedName_; - this.resourceHeader_ = { - [CLOUD_RESOURCE_HEADER]: this._dbName, - }; this._waitingRequests = []; this._inlineBeginStarted = false; this._observabilityOptions = session._observabilityOptions; + this.commonHeaders_ = getCommonHeaders( + this._dbName, + this._observabilityOptions?.enableEndToEndTracing + ); } /** @@ -432,7 +433,7 @@ export class Snapshot extends EventEmitter { reqOpts.requestOptions = this.requestOptions; } - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if ( this._getSpanner().routeToLeaderEnabled && (this._options.readWrite !== undefined || @@ -697,7 +698,7 @@ export class Snapshot extends EventEmitter { } ); - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if ( this._getSpanner().routeToLeaderEnabled && (this._options.readWrite !== undefined || @@ -1281,7 +1282,7 @@ export class Snapshot extends EventEmitter { }); }; - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if ( this._getSpanner().routeToLeaderEnabled && (this._options.readWrite !== undefined || @@ -1956,7 +1957,7 @@ export class Transaction extends Dml { statements, } as spannerClient.spanner.v1.ExecuteBatchDmlRequest; - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if (this._getSpanner().routeToLeaderEnabled) { addLeaderAwareRoutingHeader(headers); } @@ -2182,7 +2183,7 @@ export class Transaction extends Dml { this.requestOptions ); - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if (this._getSpanner().routeToLeaderEnabled) { addLeaderAwareRoutingHeader(headers); } @@ -2532,7 +2533,7 @@ export class Transaction extends Dml { transactionId, }; - const headers = this.resourceHeader_; + const headers = this.commonHeaders_; if (this._getSpanner().routeToLeaderEnabled) { addLeaderAwareRoutingHeader(headers); } diff --git a/src/v1/spanner_client.ts b/src/v1/spanner_client.ts index c774dd59c..f74b84b0b 100644 --- a/src/v1/spanner_client.ts +++ b/src/v1/spanner_client.ts @@ -895,6 +895,16 @@ export class SpannerClient { * * If the field is set to `true` but the request does not set * `partition_token`, the API returns an `INVALID_ARGUMENT` error. + * @param {boolean} [request.lastStatement] + * Optional. If set to true, this statement marks the end of the transaction. + * The transaction should be committed or aborted after this statement + * executes, and attempts to execute any other requests against this + * transaction (including reads and queries) will be rejected. + * + * For DML statements, setting this option may cause some error reporting to + * be deferred until commit time (e.g. validation of unique constraints). + * Given this, successful execution of a DML statement should not be assumed + * until a subsequent Commit call completes successfully. * @param {object} [options] * Call options. See {@link https://googleapis.dev/nodejs/google-gax/latest/interfaces/CallOptions.html|CallOptions} for more details. * @returns {Promise} - The promise which resolves to an array. @@ -1010,6 +1020,16 @@ export class SpannerClient { * handled requests will yield the same response as the first execution. * @param {google.spanner.v1.RequestOptions} request.requestOptions * Common options for this request. + * @param {boolean} [request.lastStatements] + * Optional. If set to true, this request marks the end of the transaction. + * The transaction should be committed or aborted after these statements + * execute, and attempts to execute any other requests against this + * transaction (including reads and queries) will be rejected. + * + * Setting this option may cause some error reporting to be deferred until + * commit time (e.g. validation of unique constraints). Given this, successful + * execution of statements should not be assumed until a subsequent Commit + * call completes successfully. * @param {object} [options] * Call options. See {@link https://googleapis.dev/nodejs/google-gax/latest/interfaces/CallOptions.html|CallOptions} for more details. * @returns {Promise} - The promise which resolves to an array. @@ -1906,6 +1926,16 @@ export class SpannerClient { * * If the field is set to `true` but the request does not set * `partition_token`, the API returns an `INVALID_ARGUMENT` error. + * @param {boolean} [request.lastStatement] + * Optional. If set to true, this statement marks the end of the transaction. + * The transaction should be committed or aborted after this statement + * executes, and attempts to execute any other requests against this + * transaction (including reads and queries) will be rejected. + * + * For DML statements, setting this option may cause some error reporting to + * be deferred until commit time (e.g. validation of unique constraints). + * Given this, successful execution of a DML statement should not be assumed + * until a subsequent Commit call completes successfully. * @param {object} [options] * Call options. See {@link https://googleapis.dev/nodejs/google-gax/latest/interfaces/CallOptions.html|CallOptions} for more details. * @returns {Stream} diff --git a/test/database.ts b/test/database.ts index d12eb3a13..5b3ba6c97 100644 --- a/test/database.ts +++ b/test/database.ts @@ -46,7 +46,7 @@ import { CommitOptions, MutationSet, } from '../src/transaction'; - +import {SessionFactory} from '../src/session-factory'; let promisified = false; const fakePfy = extend({}, pfy, { promisifyAll(klass, options) { @@ -78,7 +78,7 @@ class FakeBatchTransaction { } } -class FakeGrpcServiceObject extends EventEmitter { +export class FakeGrpcServiceObject extends EventEmitter { calledWith_: IArguments; constructor() { super(); @@ -91,7 +91,7 @@ function fakePartialResultStream(this: Function & {calledWith_: IArguments}) { return this; } -class FakeSession { +export class FakeSession { calledWith_: IArguments; formattedName_: any; constructor() { @@ -109,7 +109,7 @@ class FakeSession { } } -class FakeSessionPool extends EventEmitter { +export class FakeSessionPool extends EventEmitter { calledWith_: IArguments; constructor() { super(); @@ -120,6 +120,37 @@ class FakeSessionPool extends EventEmitter { release() {} } +export class FakeMultiplexedSession extends EventEmitter { + calledWith_: IArguments; + constructor() { + super(); + this.calledWith_ = arguments; + } + createSession() {} + getSession() {} +} + +export class FakeSessionFactory extends EventEmitter { + calledWith_: IArguments; + constructor() { + super(); + this.calledWith_ = arguments; + } + getSession(): FakeSession | FakeMultiplexedSession { + if (process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS === 'false') { + return new FakeSession(); + } else { + return new FakeMultiplexedSession(); + } + } + getPool(): FakeSessionPool { + return new FakeSessionPool(); + } + getMultiplexedSession(): FakeMultiplexedSession { + return new FakeMultiplexedSession(); + } +} + class FakeTable { calledWith_: IArguments; constructor() { @@ -243,6 +274,7 @@ describe('Database', () => { './codec': {codec: fakeCodec}, './partial-result-stream': {partialResultStream: fakePartialResultStream}, './session-pool': {SessionPool: FakeSessionPool}, + './session-factory': {SessionFactory: FakeSessionFactory}, './session': {Session: FakeSession}, './table': {Table: FakeTable}, './transaction-runner': { @@ -295,43 +327,40 @@ describe('Database', () => { assert(database.formattedName_, formattedName); }); - it('should create a SessionPool object', () => { - assert(database.pool_ instanceof FakeSessionPool); - assert.strictEqual(database.pool_.calledWith_[0], database); - assert.strictEqual(database.pool_.calledWith_[1], POOL_OPTIONS); - }); - it('should accept a custom Pool class', () => { function FakePool() {} - FakePool.prototype.on = util.noop; - FakePool.prototype.open = util.noop; - const database = new Database( INSTANCE, NAME, FakePool as {} as db.SessionPoolConstructor ); - assert(database.pool_ instanceof FakePool); + assert(database.pool_ instanceof FakeSessionPool); }); it('should re-emit SessionPool errors', done => { const error = new Error('err'); + const sessionFactory = new SessionFactory(database, NAME); + database.on('error', err => { assert.strictEqual(err, error); done(); }); - database.pool_.emit('error', error); + sessionFactory.pool_.emit('error', error); }); - it('should open the pool', done => { - FakeSessionPool.prototype.open = () => { - FakeSessionPool.prototype.open = util.noop; - done(); - }; + it('should re-emit Multiplexed Session errors', done => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'true'; + const error = new Error('err'); - new Database(INSTANCE, NAME); + const sessionFactory = new SessionFactory(database, NAME); + + database.on('error', err => { + assert.strictEqual(err, error); + done(); + }); + sessionFactory.multiplexedSession_?.emit('error', error); }); it('should inherit from ServiceObject', done => { @@ -358,8 +387,8 @@ describe('Database', () => { calledWith.createMethod(null, options, done); }); - it('should set the resourceHeader_', () => { - assert.deepStrictEqual(database.resourceHeader_, { + it('should set the commonHeaders_', () => { + assert.deepStrictEqual(database.commonHeaders_, { [CLOUD_RESOURCE_HEADER]: database.formattedName_, }); }); @@ -400,7 +429,7 @@ describe('Database', () => { headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - database.resourceHeader_ + database.commonHeaders_ ) ); }); @@ -531,7 +560,7 @@ describe('Database', () => { assert.deepStrictEqual(METADATA, ORIGINAL_METADATA); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, database.resourceHeader_); + assert.deepStrictEqual(config.headers, database.commonHeaders_); assert.strictEqual(callback_, callback); @@ -676,7 +705,7 @@ describe('Database', () => { assert.strictEqual(args.method, 'batchWrite'); assert.deepStrictEqual(args.reqOpts, expectedReqOpts); assert.deepStrictEqual(args.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(args.headers, database.resourceHeader_); + assert.deepStrictEqual(args.headers, database.commonHeaders_); }); it('should return error when passing an empty list of mutationGroups', done => { @@ -1114,7 +1143,7 @@ describe('Database', () => { database: database.formattedName_, }); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, database.resourceHeader_); + assert.deepStrictEqual(config.headers, database.commonHeaders_); assert.strictEqual(callback, assert.ifError); }; @@ -1386,7 +1415,7 @@ describe('Database', () => { name: database.formattedName_, }); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, database.resourceHeader_); + assert.deepStrictEqual(config.headers, database.commonHeaders_); return requestReturnValue; }; @@ -1413,7 +1442,7 @@ describe('Database', () => { database: database.formattedName_, }); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, database.resourceHeader_); + assert.deepStrictEqual(config.headers, database.commonHeaders_); done(); }; @@ -2038,7 +2067,7 @@ describe('Database', () => { statements: STATEMENTS, }); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, database.resourceHeader_); + assert.deepStrictEqual(config.headers, database.commonHeaders_); assert.strictEqual(callback, assert.ifError); return requestReturnValue; }; @@ -2096,6 +2125,7 @@ describe('Database', () => { database: database.formattedName_, session: { creatorRole: database.databaseRole, + labels: null, }, }); assert.strictEqual(config.gaxOpts, gaxOptions); @@ -2103,7 +2133,7 @@ describe('Database', () => { config.headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - database.resourceHeader_ + database.commonHeaders_ ) ); @@ -2119,6 +2149,7 @@ describe('Database', () => { database: database.formattedName_, session: { creatorRole: database.databaseRole, + labels: null, }, }); @@ -2305,6 +2336,39 @@ describe('Database', () => { assert.strictEqual(bounds, fakeTimestampBounds); }); + it('should throw error if maxStaleness is passed in the timestamp bounds to the snapshot', () => { + const fakeTimestampBounds = {maxStaleness: 10}; + + database.getSnapshot(fakeTimestampBounds, err => { + assert.strictEqual(err.code, 3); + assert.strictEqual( + err.message, + 'maxStaleness / minReadTimestamp is not supported for multi-use read-only transactions.' + ); + }); + }); + + it('should throw error if minReadTimestamp is passed in the timestamp bounds to the snapshot', () => { + const fakeTimestampBounds = {minReadTimestamp: 10}; + + database.getSnapshot(fakeTimestampBounds, err => { + assert.strictEqual(err.code, 3); + assert.strictEqual( + err.message, + 'maxStaleness / minReadTimestamp is not supported for multi-use read-only transactions.' + ); + }); + }); + + it('should pass when maxStaleness is undefined', () => { + const fakeTimestampBounds = {minReadTimestamp: undefined}; + + database.getSnapshot(fakeTimestampBounds, assert.ifError); + + const bounds = snapshotStub.lastCall.args[0]; + assert.strictEqual(bounds, fakeTimestampBounds); + }); + it('should begin a snapshot', () => { beginSnapshotStub.callsFake(() => {}); @@ -2489,7 +2553,7 @@ describe('Database', () => { assert.strictEqual(config.method, 'listSessions'); assert.deepStrictEqual(config.reqOpts, expectedReqOpts); assert.deepStrictEqual(config.gaxOpts, gaxOpts); - assert.deepStrictEqual(config.headers, database.resourceHeader_); + assert.deepStrictEqual(config.headers, database.commonHeaders_); done(); }; @@ -2662,7 +2726,7 @@ describe('Database', () => { assert.notStrictEqual(config.reqOpts, OPTIONS); assert.deepStrictEqual(config.gaxOpts, OPTIONS.gaxOptions); - assert.deepStrictEqual(config.headers, database.resourceHeader_); + assert.deepStrictEqual(config.headers, database.commonHeaders_); return returnValue; }; @@ -3269,7 +3333,7 @@ describe('Database', () => { assert.notStrictEqual(config.reqOpts, QUERY); assert.deepStrictEqual(QUERY, ORIGINAL_QUERY); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, database.resourceHeader_); + assert.deepStrictEqual(config.headers, database.commonHeaders_); done(); }; diff --git a/test/index.ts b/test/index.ts index 9be38bb6f..5952c3dcd 100644 --- a/test/index.ts +++ b/test/index.ts @@ -334,8 +334,8 @@ describe('Spanner', () => { ); }); - it('should set the resourceHeader_', () => { - assert.deepStrictEqual(spanner.resourceHeader_, { + it('should set the commonHeaders_', () => { + assert.deepStrictEqual(spanner.commonHeaders_, { [CLOUD_RESOURCE_HEADER]: spanner.projectFormattedName_, }); }); @@ -753,7 +753,7 @@ describe('Spanner', () => { }, }); assert.strictEqual(config.gaxOpts, undefined); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); done(); }; spanner.createInstance(NAME, CONFIG, assert.ifError); @@ -934,7 +934,7 @@ describe('Spanner', () => { assert.deepStrictEqual(OPTIONS, ORIGINAL_OPTIONS); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); done(); }; @@ -962,7 +962,7 @@ describe('Spanner', () => { assert.notStrictEqual(config.gaxOpts, gaxOptions); assert.notDeepStrictEqual(config.gaxOpts, gaxOptions); assert.deepStrictEqual(config.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); done(); }; @@ -998,7 +998,7 @@ describe('Spanner', () => { assert.notStrictEqual(config.gaxOpts, gaxOptions); assert.notDeepStrictEqual(config.gaxOpts, gaxOptions); assert.deepStrictEqual(config.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); done(); }; @@ -1126,7 +1126,7 @@ describe('Spanner', () => { assert.deepStrictEqual(OPTIONS, ORIGINAL_OPTIONS); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); return returnValue as Duplex; }; @@ -1153,7 +1153,7 @@ describe('Spanner', () => { assert.notStrictEqual(config.gaxOpts, gaxOptions); assert.notDeepStrictEqual(config.gaxOpts, gaxOptions); assert.deepStrictEqual(config.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); return returnValue; }; @@ -1188,7 +1188,7 @@ describe('Spanner', () => { assert.notStrictEqual(config.gaxOpts, gaxOptions); assert.notDeepStrictEqual(config.gaxOpts, gaxOptions); assert.deepStrictEqual(config.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); return returnValue; }; @@ -1273,7 +1273,7 @@ describe('Spanner', () => { }, }); assert.strictEqual(config.gaxOpts, undefined); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); done(); }; spanner.createInstanceConfig(NAME, CONFIG, assert.ifError); @@ -1402,7 +1402,7 @@ describe('Spanner', () => { const gaxOpts = config.gaxOpts; assert.deepStrictEqual(gaxOpts, options.gaxOptions); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); return returnValue; }; @@ -1431,7 +1431,7 @@ describe('Spanner', () => { assert.notStrictEqual(config.gaxOpts, gaxOptions); assert.notDeepStrictEqual(config.gaxOpts, gaxOptions); assert.deepStrictEqual(config.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); done(); }; @@ -1466,7 +1466,7 @@ describe('Spanner', () => { assert.notStrictEqual(config.gaxOpts, gaxOptions); assert.notDeepStrictEqual(config.gaxOpts, gaxOptions); assert.deepStrictEqual(config.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); done(); }; @@ -1686,7 +1686,7 @@ describe('Spanner', () => { assert.notStrictEqual(reqOpts, OPTIONS); assert.deepStrictEqual(config.gaxOpts, OPTIONS.gaxOptions); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); return returnValue; }; @@ -1740,7 +1740,7 @@ describe('Spanner', () => { assert.notStrictEqual(config.gaxOpts, gaxOptions); assert.notDeepStrictEqual(config.gaxOpts, gaxOptions); assert.deepStrictEqual(config.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); return returnValue; }; @@ -1780,7 +1780,7 @@ describe('Spanner', () => { assert.notStrictEqual(config.gaxOpts, gaxOptions); assert.notDeepStrictEqual(config.gaxOpts, gaxOptions); assert.deepStrictEqual(config.gaxOpts, expectedGaxOpts); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); return returnValue; }; @@ -1817,7 +1817,7 @@ describe('Spanner', () => { const gaxOpts = config.gaxOpts; assert.deepStrictEqual(gaxOpts, options.gaxOptions); - assert.deepStrictEqual(config.headers, spanner.resourceHeader_); + assert.deepStrictEqual(config.headers, spanner.commonHeaders_); return returnValue; }; diff --git a/test/instance.ts b/test/instance.ts index 30c3d3b4d..7d6462dfb 100644 --- a/test/instance.ts +++ b/test/instance.ts @@ -174,8 +174,8 @@ describe('Instance', () => { calledWith.createMethod(null, options, done); }); - it('should set the resourceHeader_', () => { - assert.deepStrictEqual(instance.resourceHeader_, { + it('should set the commonHeaders_', () => { + assert.deepStrictEqual(instance.commonHeaders_, { [CLOUD_RESOURCE_HEADER]: instance.formattedName_, }); }); @@ -218,7 +218,7 @@ describe('Instance', () => { createStatement: 'CREATE DATABASE `' + NAME + '`', }); assert.strictEqual(config.gaxOpts, undefined); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); done(); }; @@ -556,7 +556,7 @@ describe('Instance', () => { name: instance.formattedName_, }); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); callback(); // done() }; @@ -930,7 +930,7 @@ describe('Instance', () => { assert.deepStrictEqual(OPTIONS, ORIGINAL_OPTIONS); assert.deepStrictEqual(config.gaxOpts, OPTIONS.gaxOptions); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); done(); }; @@ -1120,7 +1120,7 @@ describe('Instance', () => { assert.notStrictEqual(config.reqOpts, OPTIONS); assert.deepStrictEqual(config.gaxOpts, OPTIONS.gaxOptions); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); return returnValue; }; @@ -1221,7 +1221,7 @@ describe('Instance', () => { name: instance.formattedName_, }); assert.strictEqual(config.gaxOpts, undefined); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); return requestReturnValue; }; @@ -1317,7 +1317,7 @@ describe('Instance', () => { assert.deepStrictEqual(METADATA, ORIGINAL_METADATA); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); assert.strictEqual(callback_, callback); @@ -1369,7 +1369,7 @@ describe('Instance', () => { assert.deepStrictEqual(OPTIONS, ORIGINAL_OPTIONS); assert.deepStrictEqual(config.gaxOpts, options.gaxOptions); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); done(); }; @@ -1555,7 +1555,7 @@ describe('Instance', () => { assert.notStrictEqual(config.reqOpts, OPTIONS); assert.deepStrictEqual(config.gaxOpts, OPTIONS.gaxOptions); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); return returnValue; }; @@ -1822,7 +1822,7 @@ describe('Instance', () => { assert.deepStrictEqual(OPTIONS, ORIGINAL_OPTIONS); assert.deepStrictEqual(config.gaxOpts, options.gaxOptions); - assert.deepStrictEqual(config.headers, instance.resourceHeader_); + assert.deepStrictEqual(config.headers, instance.commonHeaders_); done(); }; diff --git a/test/multiplexed-session.ts b/test/multiplexed-session.ts new file mode 100644 index 000000000..5e78d04ce --- /dev/null +++ b/test/multiplexed-session.ts @@ -0,0 +1,315 @@ +/*! + * Copyright 2024 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 {beforeEach, afterEach, describe, it} from 'mocha'; +import * as events from 'events'; +import * as sinon from 'sinon'; +import {Database} from '../src/database'; +import {Session} from '../src/session'; +import { + MultiplexedSession, + MUX_SESSION_AVAILABLE, + MUX_SESSION_CREATE_ERROR, +} from '../src/multiplexed-session'; +import {Transaction} from '../src/transaction'; +import {FakeTransaction} from './session-pool'; + +describe('MultiplexedSession', () => { + let multiplexedSession; + + function noop() {} + const DATABASE = { + createSession: noop, + databaseRole: 'parent_role', + } as unknown as Database; + + let fakeMuxSession; + let createSessionStub; + const sandbox = sinon.createSandbox(); + + const createSession = (name = 'id', props?): Session => { + props = props || {multiplexed: true}; + + return Object.assign(new Session(DATABASE, name), props, { + create: sandbox.stub().resolves(), + transaction: sandbox.stub().returns(new FakeTransaction()), + }); + }; + + beforeEach(() => { + fakeMuxSession = createSession(); + createSessionStub = sandbox + .stub(DATABASE, 'createSession') + .withArgs({multiplexed: true}) + .callsFake(() => { + return Promise.resolve([fakeMuxSession]); + }); + multiplexedSession = new MultiplexedSession(DATABASE); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('instantiation', () => { + it('should correctly initialize the fields', () => { + assert.strictEqual(multiplexedSession.database, DATABASE); + assert.strictEqual(multiplexedSession.refreshRate, 7); + assert.deepStrictEqual(multiplexedSession._multiplexedSession, null); + assert(multiplexedSession instanceof events.EventEmitter); + }); + + it('should correctly initialize the isMultiplexedEnabled field when GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS is enabled', () => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'true'; + const multiplexedSession = new MultiplexedSession(DATABASE); + assert.strictEqual( + (multiplexedSession as MultiplexedSession).isMultiplexedEnabled, + true + ); + }); + + it('should correctly initialize the isMultiplexedEnabled field when GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS is disabled', () => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'false'; + const multiplexedSession = new MultiplexedSession(DATABASE); + assert.strictEqual( + (multiplexedSession as MultiplexedSession).isMultiplexedEnabled, + false + ); + }); + }); + + describe('createSession', () => { + let _createSessionStub; + let _maintainStub; + + beforeEach(() => { + _maintainStub = sandbox.stub(multiplexedSession, '_maintain'); + _createSessionStub = sandbox + .stub(multiplexedSession, '_createSession') + .resolves(); + }); + + it('should create mux session', () => { + multiplexedSession.createSession(); + assert.strictEqual(_createSessionStub.callCount, 1); + }); + + it('should start housekeeping', done => { + multiplexedSession.createSession(); + setImmediate(() => { + try { + assert.strictEqual(_maintainStub.callCount, 1); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('should propagate errors for Multiplexed Session which gets emitted', async () => { + const multiplexedSession = new MultiplexedSession(DATABASE); + const fakeError = new Error(); + sandbox.stub(multiplexedSession, '_createSession').rejects(fakeError); + const errorPromise = new Promise((resolve, reject) => { + multiplexedSession.once('error', err => { + try { + assert.strictEqual(err, fakeError); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + + multiplexedSession.createSession(); + + await errorPromise; + }); + }); + + describe('_maintain', () => { + let clock; + let createSessionStub; + + beforeEach(() => { + createSessionStub = sandbox + .stub(multiplexedSession, '_createSession') + .resolves(); + clock = sandbox.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should set an interval to refresh mux sessions', () => { + const expectedInterval = + multiplexedSession.refreshRate! * 24 * 60 * 60000; + + multiplexedSession._maintain(); + clock.tick(expectedInterval); + assert.strictEqual(createSessionStub.callCount, 1); + }); + }); + + describe('_createSession', () => { + it('should create the mux sessions with multiplexed option', async () => { + await multiplexedSession._createSession(); + assert.strictEqual(createSessionStub.callCount, 1); + assert.deepStrictEqual(createSessionStub.lastCall.args[0], { + multiplexed: true, + }); + }); + + it('should emit the MUX_SESSION_AVAILABLE event on successfully creating mux session', done => { + multiplexedSession.on(MUX_SESSION_AVAILABLE, () => { + assert.strictEqual( + multiplexedSession._multiplexedSession, + fakeMuxSession + ); + done(); + }); + multiplexedSession._createSession(); + }); + + it('should reject with any request errors', async () => { + const error = new Error(MUX_SESSION_CREATE_ERROR); + createSessionStub.rejects(error); + + try { + await multiplexedSession._createSession(); + throw new Error('Should not make it this far.'); + } catch (e) { + assert.strictEqual(e, error); + } + }); + + it('should emit the error event on failed creation of mux session', done => { + const error = new Error(MUX_SESSION_CREATE_ERROR); + createSessionStub.rejects(error); + multiplexedSession.on(MUX_SESSION_CREATE_ERROR, () => { + done(); + }); + multiplexedSession._createSession().catch(err => { + assert.strictEqual(err, error); + }); + }); + }); + + describe('getSession', () => { + it('should acquire a session', done => { + sandbox.stub(multiplexedSession, '_acquire').resolves(fakeMuxSession); + multiplexedSession.getSession((err, session) => { + assert.ifError(err); + assert.strictEqual(session, fakeMuxSession); + done(); + }); + }); + + it('should pass any errors to the callback', done => { + const error = new Error('err'); + sandbox.stub(multiplexedSession, '_acquire').rejects(error); + multiplexedSession.getSession(err => { + assert.strictEqual(err, error); + done(); + }); + }); + + it('should pass back the session and txn', done => { + const fakeTxn = new FakeTransaction() as unknown as Transaction; + fakeMuxSession.txn = fakeTxn; + sandbox.stub(multiplexedSession, '_acquire').resolves(fakeMuxSession); + multiplexedSession.getSession((err, session, txn) => { + assert.ifError(err); + assert.strictEqual(session, fakeMuxSession); + assert.strictEqual(txn, fakeTxn); + done(); + }); + }); + }); + + describe('_acquire', () => { + it('should return a session', async () => { + sandbox.stub(multiplexedSession, '_getSession').resolves(fakeMuxSession); + const session = await multiplexedSession._acquire(); + assert.strictEqual(session, fakeMuxSession); + }); + + it('should have the multiplexed property set to true', async () => { + sandbox.stub(multiplexedSession, '_getSession').resolves(fakeMuxSession); + const session = await multiplexedSession._acquire(); + assert.strictEqual(session.multiplexed, true); + assert.strictEqual(fakeMuxSession.multiplexed, true); + }); + }); + + describe('_getSession', () => { + it('should return a session if one is available', async () => { + multiplexedSession._multiplexedSession = fakeMuxSession; + assert.doesNotThrow(async () => { + const session = await multiplexedSession._getSession(); + assert.strictEqual(session, fakeMuxSession); + }); + }); + + it('should wait for a pending session to become available', async () => { + multiplexedSession._multiplexedSession = fakeMuxSession; + setTimeout(() => multiplexedSession.emit(MUX_SESSION_AVAILABLE), 100); + const session = await multiplexedSession._getSession(); + assert.strictEqual(session, fakeMuxSession); + }); + + it('should remove the available listener', async () => { + const promise = multiplexedSession._getSession(); + setTimeout(() => multiplexedSession.emit(MUX_SESSION_AVAILABLE), 100); + assert.strictEqual( + multiplexedSession.listenerCount(MUX_SESSION_AVAILABLE), + 1 + ); + try { + await promise; + } finally { + assert.strictEqual( + multiplexedSession.listenerCount(MUX_SESSION_AVAILABLE), + 0 + ); + } + }); + + it('should remove the error listener', async () => { + const error = new Error('mux session create error'); + const promise = multiplexedSession._getSession(); + setTimeout( + () => multiplexedSession.emit(MUX_SESSION_CREATE_ERROR, error), + 100 + ); + assert.strictEqual( + multiplexedSession.listenerCount(MUX_SESSION_CREATE_ERROR), + 1 + ); + try { + await promise; + } catch (e) { + assert.strictEqual(e, error); + assert.strictEqual( + multiplexedSession.listenerCount(MUX_SESSION_CREATE_ERROR), + 0 + ); + } + }); + }); +}); diff --git a/test/session-factory.ts b/test/session-factory.ts new file mode 100644 index 000000000..47aa231e8 --- /dev/null +++ b/test/session-factory.ts @@ -0,0 +1,253 @@ +/*! + * Copyright 2024 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 {Database, Session, SessionPool} from '../src'; +import {SessionFactory} from '../src/session-factory'; +import * as sinon from 'sinon'; +import * as assert from 'assert'; +import {MultiplexedSession} from '../src/multiplexed-session'; +import {util} from '@google-cloud/common'; +import * as db from '../src/database'; +import {FakeTransaction} from './session-pool'; +import {ReleaseError} from '../src/session-pool'; + +describe('SessionFactory', () => { + let sessionFactory; + let fakeSession; + let fakeMuxSession; + const sandbox = sinon.createSandbox(); + const NAME = 'table-name'; + const POOL_OPTIONS = {}; + function noop() {} + const DATABASE = { + createSession: noop, + batchCreateSessions: noop, + databaseRole: 'parent_role', + } as unknown as Database; + + const createMuxSession = (name = 'id', props?): Session => { + props = props || {multiplexed: true}; + + return Object.assign(new Session(DATABASE, name), props, { + create: sandbox.stub().resolves(), + transaction: sandbox.stub().returns(new FakeTransaction()), + }); + }; + + const createSession = (name = 'id', props?): Session => { + props = props || {}; + + return Object.assign(new Session(DATABASE, name), props, { + create: sandbox.stub().resolves(), + transaction: sandbox.stub().returns(new FakeTransaction()), + }); + }; + + beforeEach(() => { + fakeSession = createSession(); + fakeMuxSession = createMuxSession(); + sandbox.stub(DATABASE, 'batchCreateSessions').callsFake(() => { + return Promise.resolve([[fakeSession, fakeSession, fakeSession]]); + }); + sandbox + .stub(DATABASE, 'createSession') + .withArgs({multiplexed: true}) + .callsFake(() => { + return Promise.resolve([fakeMuxSession]); + }); + sessionFactory = new SessionFactory(DATABASE, NAME, POOL_OPTIONS); + sessionFactory.parent = DATABASE; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('instantiation', () => { + describe('when GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS is disabled', () => { + before(() => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'false'; + }); + + it('should create a SessionPool object', () => { + assert(sessionFactory.pool_ instanceof SessionPool); + }); + + it('should accept a custom Pool class', () => { + function FakePool() {} + FakePool.prototype.on = util.noop; + FakePool.prototype.open = util.noop; + + const sessionFactory = new SessionFactory( + DATABASE, + NAME, + FakePool as {} as db.SessionPoolConstructor + ); + assert(sessionFactory.pool_ instanceof FakePool); + }); + + it('should open the pool', () => { + const openStub = sandbox + .stub(SessionPool.prototype, 'open') + .callsFake(() => {}); + + new SessionFactory(DATABASE, NAME, POOL_OPTIONS); + + assert.strictEqual(openStub.callCount, 1); + }); + }); + + describe('when GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS is enabled', () => { + before(() => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'true'; + }); + + it('should create a MultiplexedSession object', () => { + assert( + sessionFactory.multiplexedSession_ instanceof MultiplexedSession + ); + }); + + it('should initiate the multiplexed session creation', () => { + const createSessionStub = sandbox + .stub(MultiplexedSession.prototype, 'createSession') + .callsFake(() => {}); + + new SessionFactory(DATABASE, NAME, POOL_OPTIONS); + + assert.strictEqual(createSessionStub.callCount, 1); + }); + }); + }); + + describe('getSession', () => { + describe('when GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS is disabled', () => { + before(() => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'false'; + }); + + it('should retrieve a regular session from the pool', done => { + ( + sandbox.stub(sessionFactory.pool_, 'getSession') as sinon.SinonStub + ).callsFake(callback => callback(null, fakeSession)); + sessionFactory.getSession((err, resp) => { + assert.strictEqual(err, null); + assert.strictEqual(resp, fakeSession); + done(); + }); + }); + + it('should propagate errors when regular session retrieval fails', done => { + const fakeError = new Error(); + ( + sandbox.stub(sessionFactory.pool_, 'getSession') as sinon.SinonStub + ).callsFake(callback => callback(fakeError, null)); + sessionFactory.getSession((err, resp) => { + assert.strictEqual(err, fakeError); + assert.strictEqual(resp, null); + done(); + }); + }); + }); + + describe('when GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS is enabled', () => { + before(() => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'true'; + }); + + it('should return the multiplexed session', done => { + ( + sandbox.stub( + sessionFactory.multiplexedSession_, + 'getSession' + ) as sinon.SinonStub + ).callsFake(callback => callback(null, fakeMuxSession)); + sessionFactory.getSession((err, resp) => { + assert.strictEqual(err, null); + assert.strictEqual(resp, fakeMuxSession); + assert.strictEqual(resp?.multiplexed, true); + assert.strictEqual(fakeMuxSession.multiplexed, true); + done(); + }); + }); + + it('should propagate error when multiplexed session return fails', done => { + const fakeError = new Error(); + ( + sandbox.stub( + sessionFactory.multiplexedSession_, + 'getSession' + ) as sinon.SinonStub + ).callsFake(callback => callback(fakeError, null)); + sessionFactory.getSession((err, resp) => { + assert.strictEqual(err, fakeError); + assert.strictEqual(resp, null); + done(); + }); + }); + }); + }); + + describe('getPool', () => { + it('should return the session pool object', () => { + const pool = sessionFactory.getPool(); + assert(pool instanceof SessionPool); + assert.deepStrictEqual(pool, sessionFactory.pool_); + }); + }); + + describe('release', () => { + describe('when GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS is enabled', () => { + before(() => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'true'; + }); + + it('should not call the release method', () => { + const releaseStub = sandbox.stub(sessionFactory.pool_, 'release'); + const fakeSession = createSession(); + sessionFactory.release(fakeSession); + assert.strictEqual(releaseStub.callCount, 0); + }); + }); + + describe('when GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS is disabled', () => { + before(() => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'false'; + }); + + it('should call the release method to release a regular session', () => { + const releaseStub = sandbox.stub(sessionFactory.pool_, 'release'); + const fakeSession = createSession(); + sessionFactory.release(fakeSession); + assert.strictEqual(releaseStub.callCount, 1); + }); + + it('should propagate an error when release fails', () => { + const fakeSession = createSession(); + try { + sessionFactory.release(fakeSession); + assert.fail('Expected error was not thrown'); + } catch (error) { + assert.strictEqual( + (error as ReleaseError).message, + 'Unable to release unknown resource.' + ); + assert.strictEqual((error as ReleaseError).resource, fakeSession); + } + }); + }); + }); +}); diff --git a/test/session-pool.ts b/test/session-pool.ts index c0ea45e06..6aec11b8a 100644 --- a/test/session-pool.ts +++ b/test/session-pool.ts @@ -39,7 +39,7 @@ function FakePQueue(options) { FakePQueue.default = FakePQueue; -class FakeTransaction { +export class FakeTransaction { options; constructor(options?) { this.options = options; diff --git a/test/session.ts b/test/session.ts index 31db117bd..c5598de22 100644 --- a/test/session.ts +++ b/test/session.ts @@ -147,8 +147,8 @@ describe('Session', () => { }); }); - it('should set the resourceHeader_', () => { - assert.deepStrictEqual(session.resourceHeader_, { + it('should set the commonHeaders_', () => { + assert.deepStrictEqual(session.commonHeaders_, { [CLOUD_RESOURCE_HEADER]: session.parent.formattedName_, }); }); @@ -262,7 +262,7 @@ describe('Session', () => { name: session.formattedName_, }); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, session.resourceHeader_); + assert.deepStrictEqual(config.headers, session.commonHeaders_); assert.strictEqual(callback_, callback); return requestReturnValue; @@ -298,7 +298,7 @@ describe('Session', () => { config.headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - session.resourceHeader_ + session.commonHeaders_ ) ); callback(null, requestReturnValue); @@ -325,7 +325,7 @@ describe('Session', () => { config.headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - session.resourceHeader_ + session.commonHeaders_ ) ); return new Promise(resolve => resolve(requestReturnValue)); @@ -348,7 +348,7 @@ describe('Session', () => { name: session.formattedName_, }); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, session.resourceHeader_); + assert.deepStrictEqual(config.headers, session.commonHeaders_); return requestReturnValue; }; @@ -403,7 +403,7 @@ describe('Session', () => { sql: 'SELECT 1', }); assert.deepStrictEqual(config.gaxOpts, {}); - assert.deepStrictEqual(config.headers, session.resourceHeader_); + assert.deepStrictEqual(config.headers, session.commonHeaders_); assert.strictEqual(callback_, callback); return requestReturnValue; }; diff --git a/test/spanner.ts b/test/spanner.ts index dcc8a3300..b6ee01024 100644 --- a/test/spanner.ts +++ b/test/spanner.ts @@ -66,6 +66,7 @@ import protobuf = google.spanner.v1; import Priority = google.spanner.v1.RequestOptions.Priority; import TypeCode = google.spanner.v1.TypeCode; import NullValue = google.protobuf.NullValue; +import {SessionFactory} from '../src/session-factory'; const { AlwaysOnSampler, @@ -5075,6 +5076,23 @@ describe('Spanner with mock server', () => { done(); }); }); + + describe('session-factory', () => { + after(() => { + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'false'; + }); + + it('should not propagate any error when enabling GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS after client initialization', done => { + const database = newTestDatabase(); + // enable env after database creation + process.env.GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS = 'true'; + const sessionFactory = database.sessionFactory_ as SessionFactory; + sessionFactory.getSession((err, _) => { + assert.ifError(err); + done(); + }); + }); + }); }); function executeSimpleUpdate( diff --git a/test/transaction.ts b/test/transaction.ts index 28be1543f..590eff11e 100644 --- a/test/transaction.ts +++ b/test/transaction.ts @@ -148,8 +148,8 @@ describe('Transaction', () => { assert.strictEqual(REQUEST_STREAM.callCount, 1); }); - it('should set the resourceHeader_', () => { - assert.deepStrictEqual(snapshot.resourceHeader_, { + it('should set the commonHeaders_', () => { + assert.deepStrictEqual(snapshot.commonHeaders_, { [CLOUD_RESOURCE_HEADER]: snapshot.session.parent.formattedName_, }); }); @@ -170,7 +170,7 @@ describe('Transaction', () => { assert.strictEqual(method, 'beginTransaction'); assert.strictEqual(reqOpts.session, SESSION_NAME); assert.deepStrictEqual(gaxOpts, {}); - assert.deepStrictEqual(headers, snapshot.resourceHeader_); + assert.deepStrictEqual(headers, snapshot.commonHeaders_); }); it('should accept gaxOptions', done => { @@ -273,7 +273,7 @@ describe('Transaction', () => { assert.strictEqual(client, 'SpannerClient'); assert.strictEqual(method, 'streamingRead'); - assert.deepStrictEqual(headers, snapshot.resourceHeader_); + assert.deepStrictEqual(headers, snapshot.commonHeaders_); }); it('should use the transaction id if present', () => { @@ -624,7 +624,7 @@ describe('Transaction', () => { assert.strictEqual(client, 'SpannerClient'); assert.strictEqual(method, 'executeStreamingSql'); - assert.deepStrictEqual(headers, snapshot.resourceHeader_); + assert.deepStrictEqual(headers, snapshot.commonHeaders_); }); it('should use the transaction id if present', () => { @@ -1417,7 +1417,7 @@ describe('Transaction', () => { headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - transaction.resourceHeader_ + transaction.commonHeaders_ ) ); }); @@ -1553,7 +1553,7 @@ describe('Transaction', () => { headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - transaction.resourceHeader_ + transaction.commonHeaders_ ) ); }); @@ -1600,7 +1600,7 @@ describe('Transaction', () => { headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - transaction.resourceHeader_ + transaction.commonHeaders_ ) ); }); @@ -1624,7 +1624,7 @@ describe('Transaction', () => { headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - transaction.resourceHeader_ + transaction.commonHeaders_ ) ); }); @@ -2006,7 +2006,7 @@ describe('Transaction', () => { headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - transaction.resourceHeader_ + transaction.commonHeaders_ ) ); }); @@ -2180,7 +2180,7 @@ describe('Transaction', () => { config.headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - transaction.resourceHeader_ + transaction.commonHeaders_ ) ); done(); @@ -2228,7 +2228,7 @@ describe('Transaction', () => { headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - transaction.resourceHeader_ + transaction.commonHeaders_ ) ); }); @@ -2297,7 +2297,7 @@ describe('Transaction', () => { headers, Object.assign( {[LEADER_AWARE_ROUTING_HEADER]: true}, - pdml.resourceHeader_ + pdml.commonHeaders_ ) ); });