@google-cloud/spanner
Version:
Cloud Spanner Client Library for Node.js
2,314 lines • 87.8 kB
JavaScript
"use strict";
/*!
* Copyright 2016 Google Inc. 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.PartitionedDml = exports.MutationGroup = exports.MutationSet = exports.Transaction = exports.Dml = exports.Snapshot = void 0;
const precise_date_1 = require("@google-cloud/precise-date");
const promisify_1 = require("@google-cloud/promisify");
const helper_1 = require("./helper");
const events_1 = require("events");
const google_gax_1 = require("google-gax");
const stream_1 = require("stream");
const codec_1 = require("./codec");
const partial_result_stream_1 = require("./partial-result-stream");
const common_1 = require("./common");
const spanner_api_1 = require("@google-cloud/spanner-api");
var spannerClient = spanner_api_1.protos.google;
var google = spanner_api_1.protos.google;
var IsolationLevel = google.spanner.v1.TransactionOptions.IsolationLevel;
var ReadLockMode = google.spanner.v1.TransactionOptions.ReadWrite.ReadLockMode;
const instrument_1 = require("./instrument");
const request_id_header_1 = require("./request_id_header");
const RETRY_INFO_TYPE = 'type.googleapis.com/google.rpc.retryinfo';
const RETRY_INFO_BIN = 'google.rpc.retryinfo-bin';
let nextAffinityId = 0;
/**
* Injects a key-value pair into the gaxOpts.otherArgs.options object
* without mutating the original.
*/
function injectGaxOpt(existingOpts, key, value) {
return Object.assign({}, existingOpts, {
otherArgs: Object.assign({}, existingOpts?.otherArgs, {
options: Object.assign({}, existingOpts?.otherArgs?.options, {
[key]: value,
}),
}),
});
}
/**
* @typedef {object} TimestampBounds
* @property {boolean} [strong=true] Read at a timestamp where all previously
* committed transactions are visible.
* @property {external:PreciseDate|google.protobuf.Timestamp} [minReadTimestamp]
* Executes all reads at a `timestamp >= minReadTimestamp`.
* @property {number|google.protobuf.Timestamp} [maxStaleness] Read data at a
* `timestamp >= NOW - maxStaleness` (milliseconds).
* @property {external:PreciseDate|google.protobuf.Timestamp} [readTimestamp]
* Executes all reads at the given timestamp.
* @property {number|google.protobuf.Timestamp} [exactStaleness] Executes all
* reads at a timestamp that is `exactStaleness` (milliseconds) old.
* @property {boolean} [returnReadTimestamp=true] When true,
* {@link Snapshot#readTimestamp} will be populated after
* {@link Snapshot#begin} is called.
*/
/**
* This transaction type provides guaranteed consistency across several reads,
* but does not allow writes. Snapshot read-only transactions can be configured
* to read at timestamps in the past.
*
* When finished with the Snapshot, call {@link Snapshot#end} to
* release the underlying {@link Session}. Failure to do so can result in a
* Session leak.
*
* **This object is created and returned from {@link Database#getSnapshot}.**
*
* @class
* @hideconstructor
*
* @see [Timestamp Bounds API Documentation](https://cloud.google.com/spanner/docs/timestamp-bounds)
*
* @example
* ```
* const {Spanner} = require('@google-cloud/spanner');
* const spanner = new Spanner();
*
* const instance = spanner.instance('my-instance');
* const database = instance.database('my-database');
*
* const timestampBounds = {
* strong: true
* };
*
* database.getSnapshot(timestampBounds, (err, transaction) => {
* if (err) {
* // Error handling omitted.
* }
*
* // It should be called when the snapshot finishes.
* transaction.end();
* });
* ```
*/
class Snapshot extends events_1.EventEmitter {
_options;
_seqno = 1;
_waitingRequests;
_inlineBeginStarted;
_useInRunner = false;
_latestPreCommitToken;
id;
_affinityKey;
_bindGaxOpts;
_unbindGaxOpts;
multiplexedSessionPreviousTransactionId;
ended;
metadata;
readTimestamp;
readTimestampProto;
request;
requestStream;
session;
queryOptions;
commonHeaders_;
requestOptions;
_observabilityOptions;
_traceConfig;
_dbName;
_mutationKey;
/**
* The transaction ID.
*
* @name Snapshot#id
* @type {?(string|Buffer)}
*/
/**
* Whether or not the transaction has ended. If true, make no further
* requests, and discard the transaction.
*
* @name Snapshot#ended
* @type {boolean}
*/
/**
* The raw transaction response object. It is populated after
* {@link Snapshot#begin} is called.
*
* @name Snapshot#metadata
* @type {?TransactionResponse}
*/
/**
* **Snapshot only**
* The timestamp at which all reads are performed.
*
* @name Snapshot#readTimestamp
* @type {?external:PreciseDate}
*/
/**
* **Snapshot only**
* The protobuf version of {@link Snapshot#readTimestamp}. This is useful if
* you require microsecond precision.
*
* @name Snapshot#readTimestampProto
* @type {?google.protobuf.Timestamp}
*/
/**
* @constructor
*
* @param {Session} session The parent Session object.
* @param {TimestampBounds} [options] Snapshot timestamp bounds.
* @param {QueryOptions} [queryOptions] Default query options to use when none
* are specified for a query.
*/
constructor(session, options, queryOptions) {
super();
this.ended = false;
this.session = session;
this.queryOptions = Object.assign({}, queryOptions);
// If the session is multiplexed, generate a unique affinity key for this
// specific transaction/snapshot. This allows requests using the same shared
// multiplexed session to be distributed across different gRPC channels.
if (session.metadata && session.metadata.multiplexed) {
this._affinityKey = `mux-affinity-${process.pid}-${nextAffinityId++}`;
// Pre-construct and cache the bind gax options to avoid creating
// a new object on every request, which improves performance.
this._bindGaxOpts = {
otherArgs: {
options: {
affinityKey: this._affinityKey,
},
},
};
// Pre-construct and cache the unbind gax options. This explicitly signals
// the channel factory to release the affinity mapping when the transaction ends.
this._unbindGaxOpts = {
otherArgs: {
options: {
affinityKey: this._affinityKey,
unbind: true,
},
},
};
this.request = (config, callback) => {
let gaxOpts;
if (!config.gaxOpts || Object.keys(config.gaxOpts).length === 0) {
gaxOpts = this._bindGaxOpts;
}
else {
gaxOpts = injectGaxOpt(config.gaxOpts, 'affinityKey', this._affinityKey);
}
config = Object.assign({}, config, { gaxOpts });
return session.request(config, callback);
};
this.requestStream = (config) => {
let gaxOpts;
if (!config.gaxOpts || Object.keys(config.gaxOpts).length === 0) {
gaxOpts = this._bindGaxOpts;
}
else {
gaxOpts = injectGaxOpt(config.gaxOpts, 'affinityKey', this._affinityKey);
}
config = Object.assign({}, config, { gaxOpts });
return session.requestStream(config);
};
}
else {
this.request = session.request.bind(session);
this.requestStream = session.requestStream.bind(session);
}
const readOnly = Snapshot.encodeTimestampBounds(options || {});
this._options = { readOnly };
this._dbName = this.session.parent.formattedName_;
this._waitingRequests = [];
this._inlineBeginStarted = false;
this._observabilityOptions = session._observabilityOptions;
this.commonHeaders_ = { ...session.commonHeaders_ };
this._traceConfig = {
opts: this._observabilityOptions,
dbName: this._dbName,
};
this._latestPreCommitToken = null;
this._mutationKey = null;
}
_updatePrecommitToken(resp) {
if (this._latestPreCommitToken === null ||
this._latestPreCommitToken === undefined ||
(resp.precommitToken &&
this._latestPreCommitToken.seqNum < resp.precommitToken.seqNum)) {
this._latestPreCommitToken = resp.precommitToken;
}
}
/**
* Selects a single representative mutation from a list to be used as the
* transaction's `mutationKey`.
*
* This key is required by Spanner and is sent in the `BeginTransactionRequest`
* for read-write transactions that only contain mutations. The selection follows
* a two-tiered heuristic to choose the most significant mutation.
*
* The selection heuristic is as follows:
*
* 1. Priority of Operation Type: High-priority mutations (`delete`, `update`,
* `replace`, `insertOrUpdate`) are always chosen over low-priority
* (`insert`) mutations.
*
* 2. Selection Strategy:
* - If any high-priority mutations exist, one is chosen randomly from
* that group, ignoring the number of rows.
* - If only `insert` mutations exist, the one(s) with the largest number
* of rows are identified, and one is chosen randomly from that subset.
*
* @protected
* @param mutations The list of mutations from which to select the key.
*/
_setMutationKey(mutations) {
// return if the list is empty
if (mutations.length === 0) {
return;
}
// maintain a set of high priority keys
const HIGH_PRIORITY_KEYS = new Set([
'delete',
'update',
'replace',
'insertOrUpdate',
]);
// maintain a variable for low priority key
const LOW_PRIORITY_KEY = 'insert';
// Partition mutations into high and low priority groups.
const [highPriority, lowPriority] = mutations.reduce((acc, mutation) => {
const key = Object.keys(mutation)[0];
if (HIGH_PRIORITY_KEYS.has(key)) {
acc[0].push(mutation);
}
else if (key === LOW_PRIORITY_KEY) {
acc[1].push(mutation);
}
// return accumulated mutations list
return acc;
}, [[], []]);
// Apply the selection logic based on the rules.
if (highPriority.length > 0) {
// RULE 1: If high-priority keys exist, pick one randomly.
const randomIndex = Math.floor(Math.random() * highPriority.length);
this._mutationKey = highPriority[randomIndex];
}
else if (lowPriority.length > 0) {
// RULE 2: If only 'insert' key(s) exist, find the one with
// highest number of values
const { bestCandidates } = lowPriority.reduce((acc, mutation) => {
const size = mutation.insert?.values?.length || 0;
if (size > acc.maxSize) {
// New largest size found, start a new list
return { maxSize: size, bestCandidates: [mutation] };
}
if (size === acc.maxSize) {
// Same size as current max, add to list
acc.bestCandidates.push(mutation);
}
// return accumulated mutations list
return acc;
}, {
maxSize: -1,
bestCandidates: [],
});
// Pick randomly from the largest 'insert' mutation(s).
const randomIndex = Math.floor(Math.random() * bestCandidates.length);
this._mutationKey = bestCandidates[randomIndex];
}
else {
// No mutations to select from.
this._mutationKey = null;
}
}
/**
* Modifies transaction selector to include the multiplexed session previous
* transaction id.
* This is essential for operations that use an `inline begin`.
* @protected
* @param transaction The transaction selector object that will be mutated
* to include the multiplexed session previous transaction id.
*/
_setPreviousTransactionId(transaction) {
transaction.begin.readWrite = Object.assign({}, transaction.begin.readWrite || {}, {
multiplexedSessionPreviousTransactionId: this.multiplexedSessionPreviousTransactionId,
});
}
begin(gaxOptionsOrCallback, cb) {
const gaxOpts = typeof gaxOptionsOrCallback === 'object' ? gaxOptionsOrCallback : {};
const callback = typeof gaxOptionsOrCallback === 'function' ? gaxOptionsOrCallback : cb;
const session = this.session.formattedName_;
const options = this._options;
if (this.multiplexedSessionPreviousTransactionId &&
this.session.parent.isMuxEnabledForRW_) {
options.readWrite.multiplexedSessionPreviousTransactionId =
this.multiplexedSessionPreviousTransactionId;
}
const reqOpts = {
session,
options,
};
if (this._mutationKey) {
reqOpts.mutationKey = this._mutationKey;
}
// Only hand crafted read-write transactions will be able to set a
// transaction tag for the BeginTransaction RPC. Also, this.requestOptions
// is only set in the constructor of Transaction, which is the constructor
// for read/write transactions.
if (this.requestOptions) {
reqOpts.requestOptions = this.requestOptions;
}
const headers = this.commonHeaders_;
if (this._getSpanner().routeToLeaderEnabled &&
(this._options.readWrite !== undefined ||
this._options.partitionedDml !== undefined)) {
(0, common_1.addLeaderAwareRoutingHeader)(headers);
}
return (0, instrument_1.startTrace)('Snapshot.begin', {
transactionTag: this.requestOptions?.transactionTag,
...this._traceConfig,
}, span => {
span.addEvent('Begin Transaction');
this.request({
client: 'SpannerClient',
method: 'beginTransaction',
reqOpts,
gaxOpts,
headers: (0, request_id_header_1.injectRequestIDIntoHeaders)(headers, this.session),
}, (err, resp) => {
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
else {
this._updatePrecommitToken(resp);
this._update(resp, span);
}
span.end();
callback(err, resp);
});
});
}
/**
* A KeyRange represents a range of rows in a table or index.
*
* A range has a start key and an end key. These keys can be open or closed,
* indicating if the range includes rows with that key.
*
* Keys are represented by an array of strings where the nth value in the list
* corresponds to the nth component of the table or index primary key.
*
* @typedef {object} KeyRange
* @property {string[]} [startClosed] If the start is closed, then the range
* includes all rows whose first key columns exactly match.
* @property {string[]} [startOpen] If the start is open, then the range
* excludes rows whose first key columns exactly match.
* @property {string[]} [endClosed] If the end is closed, then the range
* includes all rows whose first key columns exactly match.
* @property {string[]} [endOpen] If the end is open, then the range excludes
* rows whose first key columns exactly match.
*/
/**
* Read request options. This includes all standard ReadRequest options as
* well as several convenience properties.
*
* @see [StreamingRead API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.StreamingRead)
* @see [ReadRequest API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ReadRequest)
*
* @typedef {object} ReadRequest
* @property {string} table The name of the table in the database to be read.
* @property {string[]} columns The columns of the table to be returned for each
* row matching this query.
* @property {string[]|string[][]} keys The primary or index keys of the rows in this table to be
* yielded. If using a composite key, provide an array within this array.
* See the example below.
* @property {KeyRange[]} [ranges] An alternative to the keys property; this can
* be used to define a range of keys to be yielded.
* @property {string} [index] The name of an index on the table if a
* different index than the primary key should be used to determine which rows to return.
* @property {boolean} [json=false] Receive the rows as serialized objects. This
* is the equivalent of calling `toJSON()` on each row.
* @property {JSONOptions} [jsonOptions] Configuration options for the serialized
* objects.
* @property {object} [keySet] Defines a collection of keys and/or key ranges to
* read.
* @property {number} [limit] The number of rows to yield.
* @property {Buffer} [partitionToken]
* If present, results will be restricted to the specified partition
* previously created using PartitionRead(). There must be an exact
* match for the values of fields common to this message and the
* PartitionReadRequest message used to create this partition_token.
* @property {google.spanner.v1.RequestOptions} [requestOptions]
* Common options for this request.
* @property {google.spanner.v1.IDirectedReadOptions} [directedReadOptions]
* Indicates which replicas or regions should be used for non-transactional reads or queries.
* @property {object} [gaxOptions]
* Call options. See {@link https://googleapis.dev/nodejs/google-gax/latest/interfaces/CallOptions.html|CallOptions}
* for more details.
*/
/**
* Create a readable object stream to receive rows from the database using key
* lookups and scans.
*
* Wrapper around {@link v1.SpannerClient#streamingRead}.
*
* @see {@link v1.SpannerClient#streamingRead}
* @see [StreamingRead API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.StreamingRead)
* @see [ReadRequest API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ReadRequest)
*
* @fires PartialResultStream#response
* @fires PartialResultStream#stats
*
* @param {string} table The table to read from.
* @param {ReadRequest} query Configuration object. See official
* [`ReadRequest`](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ReadRequest).
* API documentation.
* @returns {ReadableStream} A readable stream that emits rows.
*
* @example
* ```
* transaction.createReadStream('Singers', {
* keys: ['1'],
* columns: ['SingerId', 'name']
* })
* .on('error', function(err) {})
* .on('data', function(row) {
* // row = [
* // {
* // name: 'SingerId',
* // value: '1'
* // },
* // {
* // name: 'Name',
* // value: 'Eddie Wilson'
* // }
* // ]
* })
* .on('end', function() {
* // All results retrieved.
* });
*
* ```
* @example Provide an array for `query.keys` to read with a
* composite key.
* ```
* const query = {
* keys: [
* [
* 'Id1',
* 'Name1'
* ],
* [
* 'Id2',
* 'Name2'
* ]
* ],
* // ...
* };
* ```
*
* @example Rows are returned as an array of object arrays. Each
* object has a `name` and `value` property. To get a serialized object, call
* `toJSON()`.
* ```
* transaction.createReadStream('Singers', {
* keys: ['1'],
* columns: ['SingerId', 'name']
* })
* .on('error', function(err) {})
* .on('data', function(row) {
* // row.toJSON() = {
* // SingerId: '1',
* // Name: 'Eddie Wilson'
* // }
* })
* .on('end', function() {
* // All results retrieved.
* });
* ```
*
* @example Alternatively, set `query.json` to `true`, and this step
* will perform automatically.
* ```
* transaction.createReadStream('Singers', {
* keys: ['1'],
* columns: ['SingerId', 'name'],
* json: true,
* })
* .on('error', function(err) {})
* .on('data', function(row) {
* // row = {
* // SingerId: '1',
* // Name: 'Eddie Wilson'
* // }
* })
* .on('end', function() {
* // All results retrieved.
* });
* ```
*
* @example If you anticipate many results, you can end a stream
* early to prevent unnecessary processing and API requests.
* ```
* transaction.createReadStream('Singers', {
* keys: ['1'],
* columns: ['SingerId', 'name']
* })
* .on('data', function(row) {
* this.end();
* });
* ```
*/
createReadStream(table, request = {}) {
const { gaxOptions, json, jsonOptions, maxResumeRetries, requestOptions, columnsMetadata, } = request;
const keySet = Snapshot.encodeKeySet(request);
const transaction = {};
if (this.id) {
transaction.id = this.id;
}
else if (this._options.readWrite) {
transaction.begin = this._options;
}
else {
transaction.singleUse = this._options;
}
if (!this.id &&
this._options.readWrite &&
this.session.parent.isMuxEnabledForRW_) {
this._setPreviousTransactionId(transaction);
}
const directedReadOptions = this._getDirectedReadOptions(request.directedReadOptions);
request = Object.assign({}, request);
delete request.gaxOptions;
delete request.json;
delete request.jsonOptions;
delete request.maxResumeRetries;
delete request.keys;
delete request.ranges;
delete request.requestOptions;
delete request.directedReadOptions;
delete request.columnsMetadata;
const reqOpts = Object.assign(request, {
session: this.session.formattedName_,
requestOptions: this.configureTagOptions(typeof transaction.singleUse !== 'undefined', this.requestOptions?.transactionTag ?? undefined, requestOptions),
directedReadOptions: directedReadOptions,
transaction,
table,
keySet,
});
const headers = this.commonHeaders_;
if (this._getSpanner().routeToLeaderEnabled &&
(this._options.readWrite !== undefined ||
this._options.partitionedDml !== undefined)) {
(0, common_1.addLeaderAwareRoutingHeader)(headers);
}
const traceConfig = {
...this._traceConfig,
tableName: table,
transactionTag: this.requestOptions?.transactionTag,
requestTag: requestOptions?.requestTag,
};
return (0, instrument_1.startTrace)('Snapshot.createReadStream', traceConfig, span => {
let attempt = 0;
const database = this.session.parent;
const nthRequest = (0, request_id_header_1.nextNthRequest)(database);
const makeRequest = (resumeToken) => {
if (this.id && transaction.begin) {
delete transaction.begin;
transaction.id = this.id;
}
attempt++;
if (!resumeToken) {
if (attempt === 1) {
span.addEvent('Starting stream');
}
else {
span.addEvent('Re-attempting start stream', { attempt: attempt });
}
}
else {
span.addEvent('Resuming stream', {
resume_token: resumeToken.toString(),
attempt: attempt,
});
}
return this.requestStream({
client: 'SpannerClient',
method: 'streamingRead',
reqOpts: Object.assign({}, reqOpts, { resumeToken }),
gaxOpts: gaxOptions,
headers: (0, request_id_header_1.injectRequestIDIntoHeaders)(headers, this.session, nthRequest, attempt),
});
};
const resultStream = (0, partial_result_stream_1.partialResultStream)(this._wrapWithIdWaiter(makeRequest), {
json,
jsonOptions,
maxResumeRetries,
columnsMetadata,
gaxOptions,
})
?.on('response', response => {
this._updatePrecommitToken(response);
if (response.metadata && response.metadata.transaction && !this.id) {
this._update(response.metadata.transaction, span);
}
})
.on('error', err => {
(0, instrument_1.setSpanError)(span, err);
const wasAborted = isErrorAborted(err);
if (!this.id && this._useInRunner && !wasAborted) {
// TODO: resolve https://github.com/googleapis/nodejs-spanner/issues/2170
void this.begin();
}
else {
if (wasAborted) {
span.addEvent('Stream broken. Not safe to retry', {
'transaction.id': this.id?.toString(),
});
}
}
span.end();
})
.on('end', err => {
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
span.end();
});
if (resultStream instanceof stream_1.Stream) {
(0, stream_1.finished)(resultStream, err => {
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
span.end();
});
}
return resultStream;
});
}
/**
* Let the client know you're done with a particular transaction. This should
* mainly be called for {@link Snapshot} objects, however in certain cases
* you may want to call them for {@link Transaction} objects as well.
*
* @example Calling `end` on a read only snapshot
* ```
* database.getSnapshot((err, transaction) => {
* if (err) {
* // Error handling omitted.
* }
*
* transaction.run('SELECT * FROM Singers', (err, rows) => {
* if (err) {
* // Error handling omitted.
* }
*
* // End the snapshot.
* transaction.end();
* });
* });
* ```
*
* @example Calling `end` on a read/write transaction
* ```
* database.runTransaction((err, transaction) => {
* if (err) {
* // Error handling omitted.
* }
*
* const query = 'UPDATE Account SET Balance = 1000 WHERE Key = 1';
*
* transaction.runUpdate(query, err => {
* if (err) {
* // In the event of an error, there would be nothing to rollback,
* so
* // instead of continuing, discard the
* transaction. transaction.end(); return;
* }
*
* transaction.commit(err => {});
* });
* });
* ```
*/
end() {
if (this.ended) {
return;
}
this.ended = true;
process.nextTick(() => this.emit('end'));
if (this._affinityKey) {
const database = this.session?.parent;
const spanner = database?.parent?.parent;
const client = spanner?.clients_?.get('SpannerClient');
if (client?.spannerStub) {
Promise.resolve(client.spannerStub)
.then((stub) => {
stub?.getChannel?.()?.unbind?.(this._affinityKey);
})
.catch(() => { });
}
}
}
read(table, requestOrCallback, cb) {
const rows = [];
let request;
let callback;
if (typeof requestOrCallback === 'function') {
request = {};
callback = requestOrCallback;
}
else {
request = requestOrCallback;
callback = cb;
}
return (0, instrument_1.startTrace)('Snapshot.read', {
tableName: table,
...this._traceConfig,
}, span => {
this.createReadStream(table, request)
.on('error', err => {
const e = err;
(0, instrument_1.setSpanError)(span, e);
span.end();
callback(e, null);
})
.on('data', row => rows.push(row))
.on('end', () => {
span.end();
callback(null, rows);
});
});
}
run(query, callback) {
const rows = [];
let stats;
let metadata;
(0, instrument_1.startTrace)('Snapshot.run', {
...query,
...this._traceConfig,
}, span => {
return this.runStream(query)
.on('error', err => {
(0, instrument_1.setSpanError)(span, err);
span.end();
if (!('code' in err)) {
Object.assign(err, {
code: google_gax_1.grpc.status.UNKNOWN,
details: err.message,
metadata: new google_gax_1.grpc.Metadata(),
});
}
callback(err, rows, stats, metadata);
})
.on('response', response => {
if (response.metadata) {
metadata = response.metadata;
if (metadata.transaction && !this.id) {
this._update(metadata.transaction, span);
}
}
})
.on('data', row => rows.push(row))
.on('stats', _stats => (stats = _stats))
.on('end', () => {
span.end();
callback(null, rows, stats, metadata);
});
});
}
/**
* ExecuteSql request options. This includes all standard ExecuteSqlRequest
* options as well as several convenience properties.
*
* @see [Query Syntax](https://cloud.google.com/spanner/docs/query-syntax)
* @see [ExecuteSql API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.ExecuteSql)
*
* @typedef {object} ExecuteSqlRequest
* @property {string} resumeToken The token used to resume getting results.
* @property {google.spanner.v1.ExecuteSqlRequest.QueryMode} queryMode Query plan and
* execution statistics for the SQL statement that
* produced this result set.
* @property {string} partitionToken The partition token.
* @property {number} seqno The Sequence number. This option is used internally and will be overridden.
* @property {string} sql The SQL string.
* @property {google.spanner.v1.ExecuteSqlRequest.IQueryOptions} [queryOptions]
* Default query options to use with the database. These options will be
* overridden by any query options set in environment variables or that
* are specified on a per-query basis.
* @property {google.spanner.v1.IRequestOptions} requestOptions The request options to include
* with the commit request.
* @property {Object.<string, *>} [params] A map of parameter names to values.
* @property {Object.<string, (string|ParamType)>} [types] A map of parameter
* names to types. If omitted the client will attempt to guess for all
* non-null values.
* @property {boolean} [json=false] Receive the rows as serialized objects. This
* is the equivalent of calling `toJSON()` on each row.
* @property {JSONOptions} [jsonOptions] Configuration options for the
* serialized objects.
* @property {object} [gaxOptions] Request configuration options,
* See {@link https://googleapis.dev/nodejs/google-gax/latest/interfaces/CallOptions.html|CallOptions}
* for more details.
* @property {number} [maxResumeRetries] The maximum number of times that the
* stream will retry to push data downstream, when the downstream indicates
* that it is not ready for any more data. Increase this value if you
* experience 'Stream is still not ready to receive data' errors as a
* result of a slow writer in your receiving stream.
* @property {object} [directedReadOptions]
* Indicates which replicas or regions should be used for non-transactional reads or queries.
*/
/**
* Create a readable object stream to receive resulting rows from a SQL
* statement.
*
* Wrapper around {@link v1.SpannerClient#executeStreamingSql}.
*
* @see {@link v1.SpannerClient#executeStreamingSql}
* @see [ExecuteStreamingSql API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.ExecuteStreamingSql)
* @see [ExecuteSqlRequest API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ExecuteSqlRequest)
*
* @fires PartialResultStream#response
* @fires PartialResultStream#stats
*
* @param {string|ExecuteSqlRequest} query A SQL query or
* {@link ExecuteSqlRequest} object.
* @returns {ReadableStream}
*
* @example
* ```
* const query = 'SELECT * FROM Singers';
*
* transaction.runStream(query)
* .on('error', function(err) {})
* .on('data', function(row) {
* // row = {
* // SingerId: '1',
* // Name: 'Eddie Wilson'
* // }
* })
* .on('end', function() {
* // All results retrieved.
* });
*
* ```
* @example The SQL query string can contain parameter placeholders.
* A parameter placeholder consists of '@' followed by the parameter name.
* ```
* const query = {
* sql: 'SELECT * FROM Singers WHERE name = @name',
* params: {
* name: 'Eddie Wilson'
* }
* };
*
* transaction.runStream(query)
* .on('error', function(err) {})
* .on('data', function(row) {})
* .on('end', function() {});
* ```
*
* @example If you anticipate many results, you can end a stream
* early to prevent unnecessary processing and API requests.
* ```
* transaction.runStream(query)
* .on('data', function(row) {
* this.end();
* });
* ```
*/
runStream(query) {
if (typeof query === 'string') {
query = { sql: query };
}
query = Object.assign({}, query);
query.queryOptions = Object.assign(Object.assign({}, this.queryOptions), query.queryOptions);
const { gaxOptions, json, jsonOptions, maxResumeRetries, requestOptions, columnsMetadata, } = query;
let reqOpts;
const directedReadOptions = this._getDirectedReadOptions(query.directedReadOptions);
const sanitizeRequest = () => {
query = query;
const { params, paramTypes } = Snapshot.encodeParams(query);
const transaction = {};
if (this.id) {
transaction.id = this.id;
}
else if (this._options.readWrite) {
transaction.begin = this._options;
}
else {
transaction.singleUse = this._options;
}
if (!this.id &&
this._options.readWrite &&
this.session.parent.isMuxEnabledForRW_) {
this._setPreviousTransactionId(transaction);
}
delete query.gaxOptions;
delete query.json;
delete query.jsonOptions;
delete query.maxResumeRetries;
delete query.requestOptions;
delete query.types;
delete query.directedReadOptions;
delete query.columnsMetadata;
reqOpts = Object.assign(query, {
session: this.session.formattedName_,
seqno: this._seqno++,
requestOptions: this.configureTagOptions(typeof transaction.singleUse !== 'undefined', this.requestOptions?.transactionTag ?? undefined, requestOptions),
directedReadOptions: directedReadOptions,
transaction,
params,
paramTypes,
});
};
const headers = this.commonHeaders_;
if (this._getSpanner().routeToLeaderEnabled &&
(this._options.readWrite !== undefined ||
this._options.partitionedDml !== undefined)) {
(0, common_1.addLeaderAwareRoutingHeader)(headers);
}
const traceConfig = {
transactionTag: this.requestOptions?.transactionTag,
requestTag: requestOptions?.requestTag,
...query,
...this._traceConfig,
};
return (0, instrument_1.startTrace)('Snapshot.runStream', traceConfig, span => {
let attempt = 0;
const database = this.session.parent;
const nthRequest = (0, request_id_header_1.nextNthRequest)(database);
const makeRequest = (resumeToken) => {
attempt++;
if (!resumeToken) {
if (attempt === 1) {
span.addEvent('Starting stream');
}
else {
span.addEvent('Re-attempting start stream', { attempt: attempt });
}
}
else {
span.addEvent('Resuming stream', {
resume_token: resumeToken.toString(),
attempt: attempt,
});
}
if (!reqOpts || (this.id && !reqOpts.transaction.id)) {
try {
sanitizeRequest();
}
catch (e) {
const errorStream = new stream_1.PassThrough();
(0, instrument_1.setSpanErrorAndException)(span, e);
span.end();
setImmediate(() => errorStream.destroy(e));
return errorStream;
}
}
return this.requestStream({
client: 'SpannerClient',
method: 'executeStreamingSql',
reqOpts: Object.assign({}, reqOpts, { resumeToken }),
gaxOpts: gaxOptions,
headers: (0, request_id_header_1.injectRequestIDIntoHeaders)(headers, this.session, nthRequest, attempt),
});
};
const resultStream = (0, partial_result_stream_1.partialResultStream)(this._wrapWithIdWaiter(makeRequest), {
json,
jsonOptions,
maxResumeRetries,
columnsMetadata,
gaxOptions,
})
.on('response', response => {
this._updatePrecommitToken(response);
if (response.metadata && response.metadata.transaction && !this.id) {
this._update(response.metadata.transaction, span);
}
})
.on('error', err => {
(0, instrument_1.setSpanError)(span, err);
const wasAborted = isErrorAborted(err);
if (!this.id && this._useInRunner && !wasAborted) {
span.addEvent('Stream broken. Safe to retry');
// TODO: resolve https://github.com/googleapis/nodejs-spanner/issues/2170
void this.begin();
}
else {
if (wasAborted) {
span.addEvent('Stream broken. Not safe to retry', {
'transaction.id': this.id?.toString(),
});
}
}
span.end();
})
.on('end', err => {
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
span.end();
});
if (resultStream instanceof stream_1.Stream) {
(0, stream_1.finished)(resultStream, err => {
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
span.end();
});
}
return resultStream;
});
}
/**
*
* @private
*/
configureTagOptions(singleUse, transactionTag, requestOptions = {}) {
if (!singleUse && transactionTag) {
requestOptions.transactionTag = transactionTag;
}
return requestOptions;
}
/**
* Transforms convenience options `keys` and `ranges` into a KeySet object.
*
* @private
* @static
*
* @param {ReadRequest} request The read request.
* @returns {object}
*/
static encodeKeySet(request) {
const keySet = request.keySet || {};
if (request.keys) {
keySet.keys = (0, helper_1.toArray)(request.keys).map(codec_1.codec.convertToListValue);
}
if (request.ranges) {
keySet.ranges = (0, helper_1.toArray)(request.ranges).map(range => {
const encodedRange = {};
Object.keys(range).forEach(bound => {
encodedRange[bound] = codec_1.codec.convertToListValue(range[bound]);
});
return encodedRange;
});
}
if ((0, helper_1.isEmpty)(keySet)) {
keySet.all = true;
}
return keySet;
}
/**
* Formats timestamp options into proto format.
*
* @private
* @static
*
* @param {TimestampBounds} options The user supplied options.
* @returns {object}
*/
static encodeTimestampBounds(options) {
const readOnly = {};
const { returnReadTimestamp = true } = options;
if (options.minReadTimestamp instanceof precise_date_1.PreciseDate) {
readOnly.minReadTimestamp = options.minReadTimestamp.toStruct();
}
if (options.readTimestamp instanceof precise_date_1.PreciseDate) {
readOnly.readTimestamp = options.readTimestamp.toStruct();
}
if (typeof options.maxStaleness === 'number') {
readOnly.maxStaleness = codec_1.codec.convertMsToProtoTimestamp(options.maxStaleness);
}
if (typeof options.exactStaleness === 'number') {
readOnly.exactStaleness = codec_1.codec.convertMsToProtoTimestamp(options.exactStaleness);
}
// If we didn't detect a convenience format, we'll just assume that
// they passed in a protobuf timestamp.
if ((0, helper_1.isEmpty)(readOnly)) {
Object.assign(readOnly, options);
}
readOnly.returnReadTimestamp = returnReadTimestamp;
return readOnly;
}
/**
* Encodes convenience options `param` and `types` into the proto formatted.
*
* @private
* @static
*
* @param {ExecuteSqlRequest} request The SQL request.
* @returns {object}
*/
static encodeParams(request) {
const typeMap = request.types || {};
const params = { fields: request.params?.fields || {} };
const paramTypes = request.paramTypes || {};
if (request.params && !request.params.fields) {
const fields = {};
Object.keys(request.params).forEach(param => {
const value = request.params[param];
if (!typeMap[param]) {
typeMap[param] = codec_1.codec.getType(value);
}
fields[param] = codec_1.codec.encode(value);
});
params.fields = fields;
}
if (!(0, helper_1.isEmpty)(typeMap)) {
Object.keys(typeMap).forEach(param => {
const type = typeMap[param];
if (process.env['SPANNER_ENABLE_UUID_AS_UNTYPED'] === 'true') {
const typeObject = codec_1.codec.createTypeObject(type);
if ((type.child &&
typeObject.code === 'ARRAY' &&
typeObject.arrayElementType?.code !== 'TYPE_CODE_UNSPECIFIED') ||
(!type.child && typeObject.code !== 'TYPE_CODE_UNSPECIFIED')) {
paramTypes[param] = typeObject;
}
}
else {
paramTypes[param] = codec_1.codec.createTypeObject(type);
}
});
}
return { params, paramTypes };
}
/**
* Get directed read options
* @private
* @param {google.spanner.v1.IDirectedReadOptions} directedReadOptions Request directedReadOptions object.
*/
_getDirectedReadOptions(directedReadOptions) {
if (!directedReadOptions &&
this._getSpanner().directedReadOptions &&
this._options.readOnly) {
return this._getSpanner().directedReadOptions;
}
return directedReadOptions;
}
/**
* Update transaction properties from the response.
*
* @private
*
* @param {spannerClient.spanner.v1.ITransaction} resp Response object.
*/
_update(resp, span) {
const { id, readTimestamp } = resp;
this.id = id;
this.metadata = resp;
span.addEvent('Transaction Creation Done', { id: this.id.toString() });
if (readTimestamp) {
this.readTimestampProto = readTimestamp;
this.readTimestamp = new precise_date_1.PreciseDate(readTimestamp);
}
this._releaseWaitingRequests();
}
/**
* Wrap `makeRequest` function with the lock to make sure the inline begin
* transaction can happen only once.
*
* @param makeRequest
* @private
*/
_wrapWithIdWaiter(makeRequest) {
if (this.id || !this._options.readWrite) {
return makeRequest;
}
if (!this._inlineBeginStarted) {
this._inlineBeginStarted = true;
return makeRequest;
}
// Queue subsequent requests.
return (resumeToken) => {
const streamProxy = new stream_1.Readable({
read() { },
});
this._waitingRequests.push(() => {
makeRequest(resumeToken)
.on('data', chunk => streamProxy.emit('data', chunk))
.on('error', err => streamProxy.emit('error', err))
.on('end', () => streamProxy.emit('end'));
});
return streamProxy;
};
}
_releaseWaitingRequests() {
while (this._waitingRequests.length > 0) {
const request = this._waitingRequests.shift();
request?.();
}
}
/**
* Gets the Spanner object
*
* @private
*
* @returns {Spanner}
*/
_getSpanner() {
return this.session.parent.parent.parent;
}
}
exports.Snapshot = Snapshot;
/*! Developer Documentation
*
* All async methods (except for streams) return a Promise in the event
* that a callback is omitted.
*/
(0, promisify_1.promisifyAll)(Snapshot, {
exclude: ['configureTagOptions', 'end'],
});
/**
* Never use DML class directly. Instead, it should be extended upon
* if a class requires DML capabilities.
*
* @private
* @class
*/
class Dml extends Snapshot {
runUpdate(query, callback) {
if (typeof query === 'string') {
query = { sql: query };
}
return (0, instrument_1.startTrace)('Dml.runUpdate', {
...query,
...this._traceConfig,
transactionTag: this.requestOptions?.transactionTag,
requestTag: query.requestOptions?.requestTag,
}, span => {
this.run(query, (err, rows, stats) => {
let rowCount = 0;
if (stats && stats.rowCount) {
rowCount = Math.floor(stats[stats.rowCount]);
}
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
span.end();
callback(err, rowCount);
});
});
}
}
exports.Dml = Dml;
/*! Developer Documentation
*
* All async methods (except for streams) return a Promise in the event
* that a callback is omitted.
*/
(0, promisify_1.promisifyAll)(Dml);
/**
* This type of transaction is the only way to write data into Cloud Spanner.
* These transactions rely on pessimistic locking and, if necessary, two-phase
* commit. Locking read-write transactions may abort, requiring the application
* to retry.
*
* Calling either {@link Transaction#commit} or {@link Transaction#rollback}
* signals that the transaction is finished and no further requests will be
* made. If for some reason you decide not to call one of the aformentioned
* methods, call {@link Transaction#end} to release the underlying
* {@link Session}.
*
* Running a transaction via {@link Database#runTransaction} or
* {@link Database#runTransactionAsync} automatically re-runs the
* transaction on `ABORTED` errors.
*
* {@link Database#getTransaction} returns a plain {@link Transaction}
* object, requiring the user to retry manually.
*
* @class
* @extends Snapshot
*
* @param {Session} session The parent Session object.
*
* @example
* ```
* const {Spanner} = require('@google-cloud/spanner');
* const spanner = new Spanner();
*
* const instance = spanner.instance('my-instance');
* const database = instance.database('my-database');
*
* database.runTransaction(function(err, transaction) {
* // The `transaction` object is ready for use.
* });
*
* ```
* @example To manually control retrying the transaction, use the
* `getTransaction` method.
* ```
* database.getTransaction(function(err, transaction) {
* // The `transaction` object is ready for use.
* });
* ```
*/
class Transaction extends Dml {
commitTimestamp;
commitTimestampProto;
_queuedMutations;
_retryCommit;
/**
* Timestamp at which the transaction was committed. Will be populated once
* {@link Transaction#commit} is called.
*
* @name Transaction#commitTimestamp
* @type {?external:PreciseDate}
*/
/**
* The protobuf version of {@link Transaction#commitTimestamp}. This is useful
* if you require microsecond precision.
*
* @name Transaction#commitTimestampProto
* @type {?google.protobuf.Timestamp}
*/
/**
* Execute a DML statement and get the affected row count.
*
* @name Transaction#runUpdate
*
* @see {@link Transaction#run}
*
* @param {string|object} query A DML statement or
* [`ExecuteSqlRequest`](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ExecuteSqlRequest)
* object.
* @param {object} [query.params] A map of parameter name to values.
* @param {object} [query.types] A map of parameter types.
* @param {RunUpdateCallback} [callback] Callback function.
* @returns {Promise<RunUpdateResponse>}
*
* @example
* ```
* const query = 'UPDATE Account SET Balance = 1000 WHERE Key = 1';
*
* transaction.runUpdate(query, (err, rowCount) => {
* if (err) {
* // Error handling omitted.
* }
* });
* ```
*/
constructor(session, options = {}, queryOptions, requestOptions) {
super(session, undefined, queryOptions);
this._queuedMutations = [];
this._options = { readWrite: options };
this._options.isolationLevel = IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED;
this.requestOptions = requestOptions;
this._retryCommit = false;
}
batchUpdate(queries, optionsOrCallback, cb) {
const options = typeof optionsOrCallback === 'object' ? optionsOrCallback : {};
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : cb;
const gaxOpts = 'gaxOptions' in options
? options.gaxOptions
: options;
if (!Array.isArray(queries) || !queries.length) {
const rowCounts = [];
const error = new Error('batchUpdate requires at least 1 DML statement.');
const batchError = Object.assign(error, {
code: 3, // invalid argument
rowCounts,
});
callback(batchError, rowCounts);
return;
}
const statements = queries.map(query => {
if (typeof query === 'string') {
return { sql: query };
}
const { sql } = query;
const { params, paramTypes } = Snapshot.encodeParams(query);
return { sql, params, paramTypes };
});
const transaction = {};
if (this.id) {
transaction.id = this.id;
}
else {
transaction.begin = this._options;
}
if (!this.id &&
this._options.readWrite &&
this.session.parent.isMuxEnabledForRW_) {
this._setPreviousTransactionId(transaction);
}
const requestOptionsWithTag = this.configureTagOptions(false, this.requestOptions?.transactionTag ?? undefined, options.requestOptions);
const reqOpts = {
session: this.session.formattedName_,
requestOptions: requestOptionsWithTag,
transaction,
seqno: this._seqno++,
statements,
};
const database = this.session.parent;
const headers = (0, request_id_header_1.injectRequestIDIntoHeaders)(this.commonHeaders_, this.session, (0, request_id_header_1.nextNthRequest)(database), 1);
if (this._getSpanner().routeToLeaderEnabled) {
(0, common_1.addLeaderAwareRoutingHeader)(headers);
}
const traceConfig = {
...this._traceConfig,
transactionTag: requestOptionsWithTag?.transactionTag,
requestTag: options?.requestOptions?.requestTag,
};
return (0, instrument_1.startTrace)('Transaction.batchUpdate', traceConfig, span => {
this.request({
client: 'SpannerClient',
method: 'executeBatchDml',
reqOpts,
gaxOpts,
headers: headers,
}, (err, resp) => {
let batchUpdateError;
if (err) {
const rowCounts = [];
batchUpdateError = Object.assign(err, { rowCounts });
(0, instrument_1.setSpanError)(span, batchUpdateError);
span.end();
callback(batchUpdateError, rowCounts, resp);
return;
}
this._updatePrecommitToken(resp);
const { resultSets, status } = resp;
for (const resultSet of resultSets) {
if (!this.id && resultSet.metadata?.transaction) {
this._update(resultSet.metadata.transaction, span);
}
}
const rowCounts = resultSets.map(({ stats }) => {
return ((stats &&
Number(stats[stats.rowCount])) ||
0);
});
if (status && status.code !== 0) {
const error = new Error(status.message);
batchUpdateError = Object.assign(error, {
code: status.code,
metadata: Transaction.extractKnownMetadata(status.details),
rowCounts,
});
(0, instrument_1.setSpanError)(span, batchUpdateError);
}
span.end();
callback(batchUpdateError, rowCounts, resp);
});
});
}
static extractKnownMetadata(details) {
if (details && typeof details[Symbol.iterator] === 'function') {
const metadata = new google_gax_1.grpc.Metadata();
for (const detail of details) {
if (detail.type_url === RETRY_INFO_TYPE && detail.value) {
metadata.add(RETRY_INFO_BIN, detail.value);
}
}
return metadata;
}
return undefined;
}
/**
* This method updates the _queuedMutations property of the transaction.
*
* @public
*
* @param {spannerClient.spanner.v1.Mutation[]} [mutation]
*/
setQueuedMutations(mutation) {
this._queuedMutations = mutation;
}
commit(optionsOrCallback, cb) {
const options = typeof optionsOrCallback === 'object' ? optionsOrCallback : {};
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : cb;
let gaxOpts = 'gaxOptions' in options ? options.gaxOptions : options;
const mutations = this._queuedMutations;
const session = this.session.formattedName_;
const precommitToken = this._latestPreCommitToken;
const requestOptions = options.requestOptions;
const reqOpts = {
mutations,
session,
requestOptions,
precommitToken,
};
return (0, instrument_1.startTrace)('Transaction.commit', {
transactionTag: this.requestOptions?.transactionTag,
...this._traceConfig,
}, span => {
if (this.id) {
reqOpts.transactionId = this.id;
}
else if (!this._useInRunner) {
reqOpts.singleUseTransaction = this._options;
}
else {
if (this.session.parent.isMuxEnabledForRW_) {
this._setMutationKey(mutations);
}
this.begin().then(() => {
this.commit(options, (err, resp) => {
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
span.end();
callback(err, resp);
});
}, err => {
(0, instrument_1.setSpanError)(span, err);
span.end();
callback(err, null);
});
return;
}
if ('returnCommitStats' in options &&
options.returnCommitStats) {
reqOpts.returnCommitStats = options.returnCommitStats;
}
if ('maxCommitDelay' in options &&
options.maxCommitDelay) {
reqOpts.maxCommitDelay = options.maxCommitDelay;
}
reqOpts.requestOptions = Object.assign(requestOptions || {}, this.requestOptions);
const headers = this.commonHeaders_;
if (this._getSpanner().routeToLeaderEnabled) {
(0, common_1.addLeaderAwareRoutingHeader)(headers);
}
span.addEvent('Starting Commit');
const database = this.session.parent;
if (this._affinityKey) {
if (!gaxOpts || Object.keys(gaxOpts).length === 0) {
gaxOpts = this._unbindGaxOpts;
}
else {
gaxOpts = injectGaxOpt(gaxOpts, 'unbind', true);
}
}
this.request({
client: 'SpannerClient',
method: 'commit',
reqOpts,
gaxOpts,
headers: (0, request_id_header_1.injectRequestIDIntoHeaders)(headers, this.session, (0, request_id_header_1.nextNthRequest)(database), 1),
}, (err, resp) => {
if (resp &&
'MultiplexedSessionRetry' in resp &&
!this._retryCommit) {
this._retryCommit = true;
this._updatePrecommitToken(resp);
return this.commit(options, callback);
}
this.end();
if (err) {
span.addEvent('Commit failed');
(0, instrument_1.setSpanError)(span, err);
}
else {
span.addEvent('Commit Done');
}
if (resp && resp.commitTimestamp) {
this.commitTimestampProto = resp.commitTimestamp;
this.commitTimestamp = new precise_date_1.PreciseDate(resp.commitTimestamp);
}
err = Transaction.decorateCommitError(err, mutations);
span.end();
callback(err, resp);
});
});
}
/**
* Decorates an error returned by a commit with additional information for
* specific known errors.
* @param err the error to check and decorate with additional information if possible
* @param mutations the mutations included in the commit request
* @private
*/
static decorateCommitError(err, mutations) {
if (!err) {
return err;
}
if (err.code === google_gax_1.Status.FAILED_PRECONDITION) {
const mismatchErr = Transaction.decoratePossibleJsonMismatchError(err, mutations);
if (mismatchErr) {
return mismatchErr;
}
}
return err;
}
/**
* Decorates an error returned by a commit with additional information if the
* error was returned because the application tried to insert an array of
* objects into a JSON column. An array of objects will by default be encoded
* as ARRAY<JSON>, but can also be interpreted as JSON. An application must
* specify a top-level array of objects that should be inserted into a JSON
* column as a string instead of as an array of objects.
* @param err the error returned by the commit RPC
* @param mutations the mutations included in the commit request
* @private
*/
static decoratePossibleJsonMismatchError(err, mutations) {
const errorMessage = /Invalid value for column (?<column>.+) in table (?<table>.+): Expected JSON./;
const found = err.message && err.message.match(errorMessage);
if (found && found.groups) {
const table = found.groups.table;
const column = found.groups.column;
for (const mutation of mutations) {
const write = mutation.insert ||
mutation.update ||
mutation.insertOrUpdate ||
mutation.replace;
if (write && write.table === table) {
const index = write.columns?.indexOf(column);
if (index && index > -1 && write.values) {
for (const row of write.values) {
if (row.values?.length &&
row.values.length > index &&
row.values[index].listValue) {
// If the value is an array, the client library also encoded it as
// an array. Inserting an array into a JSON column is not possible,
// although if it is encoded as a string containing a top-level JSON
// array it will work.
const additionalMessage = 'The value is an array. Convert the value to a JSON string containing an array instead in order to insert it into a JSON column. Example: `[{"key": "value 1"}, {"key": "value 2"}]` instead of [{key: "value 1"}, {key: "value 2"}]';
return {
code: err.code,
details: `${err.details} ${additionalMessage}`,
message: `${err.message} ${additionalMessage}`,
metadata: err.metadata,
name: err.name,
stack: err.stack,
};
}
}
}
}
}
}
return null;
}
/**
* Delete rows from a table.
*
* @see [Commit API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.Commit)
*
* @param {string} table The name of the table.
* @param {array} keys The keys for the rows to delete. If using a
* composite key, provide an array within this array. See the example
* below.
*
* @example
* ```
* const keys = ['Id1', 'Id2', 'Id3'];
*
* database.runTransaction(function(err, transaction) {
* if (err) {
* // Error handling omitted.
* }
*
* // Queue this mutation until later calling `commit`.
* // Note that a callback is not passed to `deleteRows`.
* transaction.deleteRows('Singers', keys);
*
* // Commit the transaction.
* transaction.commit(function(err) {
* if (!err) {
* // The rows were deleted successfully.
* }
* });
* });
*
* ```
* @example Provide an array for `keys` to delete rows with a
* composite key.
* ```
* const keys = [
* [
* 'Id1',
* 'Name1'
* ],
* [
* 'Id2',
* 'Name2'
* ]
* ];
* ```
*/
deleteRows(table, keys) {
this._queuedMutations.push(buildDeleteMutation(table, keys));
}
/**
* Insert rows of data into this table.
*
* @see [Commit API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.Commit)
*
* @param {string} table The name of the table.
* @param {object|object[]} rows A map of names to values of data to insert
* into this table.
*
* @example
* ```
* const row = {
* SingerId: 'Id3',
* Name: 'Eddie Wilson'
* };
*
* database.runTransaction(function(err, transaction) {
* if (err) {
* // Error handling omitted.
* }
*
* // Queue this mutation until later calling `commit`.
* // Note that a callback is not passed to `insert`.
* transaction.insert('Singers', row);
*
* // Commit the transaction.
* transaction.commit(function(err) {
* if (!err) {
* // The row was inserted successfully.
* }
* });
* });
*
* ```
* @example Multiple rows can be inserted at once.
* ```
* const row2 = {
* SingerId: 'Id3b',
* Name: 'Joe West'
* };
*
* database.runTransaction(function(err, transaction) {
* if (err) {
* // Error handling omitted.
* }
*
* // Queue multiple mutations until later calling `commit`.
* // Note that a callback is not passed to `insert`.
* transaction.insert('Singers', [
* row,
* row2
* ]);
*
* // Commit the transaction.
* transaction.commit(function(err) {
* if (!err) {
* // The rows were inserted successfully.
* }
* });
* });
* ```
*/
insert(table, rows) {
this._mutate('insert', table, rows);
}
/**
* Replace rows of data within a table.
*
* @see [Commit API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.Commit)
*
* @param {string} table The table to read from.
* @param {object|object[]} rows A map of names to values of data to insert
* into this table.
*
* @example
* ```
* const row = {
* SingerId: 'Id3',
* Name: 'Joe West'
* };
*
* database.runTransaction(function(err, transaction) {
* if (err) {
* // Error handling omitted.
* }
*
* // Queue this mutation until later calling `commit`.
* // Note that a callback is not passed to `replace`.
* transaction.replace('Singers', row);
*
* // Commit the transaction.
* transaction.commit(function(err) {
* if (!err) {
* // The row was replaced successfully.
* }
* });
* });
* ```
*/
replace(table, rows) {
this._mutate('replace', table, rows);
}
rollback(gaxOptionsOrCallback, cb) {
let gaxOpts = typeof gaxOptionsOrCallback === 'object' ? gaxOptionsOrCallback : {};
const callback = typeof gaxOptionsOrCallback === 'function' ? gaxOptionsOrCallback : cb;
return (0, instrument_1.startTrace)('Transaction.rollback', this._traceConfig, span => {
if (!this.id) {
span.addEvent('Transaction ID is unknown, nothing to rollback.');
span.end();
callback(null);
return;
}
const session = this.session.formattedName_;
const transactionId = this.id;
const reqOpts = {
session,
transactionId,
};
const headers = this.commonHeaders_;
if (this._getSpanner().routeToLeaderEnabled) {
(0, common_1.addLeaderAwareRoutingHeader)(headers);
}
if (this._affinityKey) {
if (!gaxOpts || Object.keys(gaxOpts).length === 0) {
gaxOpts = this._unbindGaxOpts;
}
else {
gaxOpts = injectGaxOpt(gaxOpts, 'unbind', true);
}
}
this.request({
client: 'SpannerClient',
method: 'rollback',
reqOpts,
gaxOpts,
headers: headers,
}, (err) => {
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
span.end();
this.end();
callback(err);
});
});
}
/**
* Update rows of data within a table.
*
* @see [Commit API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.Commit)
*
* @param {string} table The table to read from.
* @param {object|object[]} rows A map of names to values of data to insert
* into this table.
*
* @example
* ```
* const row = {
* SingerId: 'Id3',
* Name: 'Joe West'
* };
*
* database.runTransaction(function(err, transaction) {
* if (err) {
* // Error handling omitted.
* }
*
* // Queue this mutation until later calling `commit`.
* // Note that a callback is not passed to `update`.
* transaction.update('Singers', row);
*
* // Commit the transaction.
* transaction.commit(function(err) {
* if (!err) {
* // The row was updated successfully.
* }
* });
* });
* ```
*/
update(table, rows) {
this._mutate('update', table, rows);
}
/**
* Insert or update rows of data within a table.
*
* @see [Commit API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.Commit)
*
* @param {string} table The table to read from.
* @param {object|object[]} rows A map of names to values of data to insert
* into this table.
*
* @example
* ```
* const row = {
* SingerId: 'Id3',
* Name: 'Joe West'
* };
*
* database.runTransaction(function(err, transaction) {
* if (err) {
* // Error handling omitted.
* }
*
* // Queue this mutation until later calling `commit`.
* // Note that a callback is not passed to `upsert`.
* transaction.upsert('Singers', row);
*
* // Commit the transaction.
* transaction.commit(function(err) {
* if (!err) {
* // The row was updated or inserted successfully.
* }
* });
* });
* ```
*/
upsert(table, rows) {
this._mutate('insertOrUpdate', table, rows);
}
/**
* Formats the mutations.
*
* @see [Commit API Documentation](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.Spanner.Commit)
*
* @private
*
* @param {string} method CRUD method (insert, update, etc.).
* @param {string} table Table to perform mutations in.
* @param {object} rows Hash of key value pairs.
*/
_mutate(method, table, keyVals) {
this._queuedMutations.push(buildMutation(method, table, keyVals));
}
/**
* Takes a list of rows and returns all unique column names.
*
* @private
*
* @param {object[]} rows The rows.
* @returns {string[]}
*/
static getUniqueKeys(rows) {
const allKeys = [];
rows.forEach(row => allKeys.push(...Object.keys(row)));
const unique = new Set(allKeys);
return Array.from(unique).sort();
}
/**
* Mark transaction as started from the runner.
*/
useInRunner() {
this._useInRunner = true;
}
/**
* Use optimistic concurrency control for the transaction.
*
* In this concurrency mode, operations during the execution phase, i.e.,
* reads and queries, are performed without acquiring locks, and transactional
* consistency is ensured by running a validation process in the commit phase
* (when any needed locks are acquired). The validation process succeeds only
* if there are no conflicting committed transactions (that committed
* mutations to the read data at a commit timestamp after the read timestamp).
*
* @deprecated Set readLockMode through setReadWriteTransactionOptions instead.
*/
useOptimisticLock() {
this._options.readWrite.readLockMode = ReadLockMode.OPTIMISTIC;
}
/**
* Use option excludeTxnFromChangeStreams to exclude read/write transactions
* from being tracked in change streams.
*
* Enabling this options to true will effectively disable change stream tracking
* for a specified transaction, allowing read/write transaction to operate without being
* included in change streams.
*/
excludeTxnFromChangeStreams() {
this._options.excludeTxnFromChangeStreams = true;
}
setReadWriteTransactionOptions(options) {
/**
* Set option excludeTxnFromChangeStreams=true to exclude read/write transactions
* from being tracked in change streams.
*/
if (options?.excludeTxnFromChangeStreams) {
this._options.excludeTxnFromChangeStreams = true;
}
/**
* Set isolation level.
*/
this._options.isolationLevel = options?.isolationLevel
? options?.isolationLevel
: this._getSpanner().defaultTransactionOptions.isolationLevel;
/**
* Set read lock mode.
*/
this._options.readWrite.readLockMode = options?.readLockMode
? options?.readLockMode
: this._getSpanner().defaultTransactionOptions.readLockMode;
}
}
exports.Transaction = Transaction;
/*! Developer Documentation
*
* All async methods (except for streams) return a Promise in the event
* that a callback is omitted.
*/
(0, promisify_1.promisifyAll)(Transaction, {
exclude: ['deleteRows', 'insert', 'replace', 'update', 'upsert'],
});
/**
* Builds an array of protobuf Mutations from the given row(s).
*
* @param {string} method - CRUD method (insert, update, etc.).
* @param {string} table - Table to perform mutations in.
* @param {object | object[]} keyVals - Hash of key-value pairs representing the rows.
* @returns {spannerClient.spanner.v1.Mutation} - The formatted mutation.
* @throws {GoogleError} - If a row does not contain the correct number of columns.
*/
function buildMutation(method, table, keyVals) {
const rows = (0, helper_1.toArray)(keyVals);
const columns = Transaction.getUniqueKeys(rows);
const values = rows.map((row, index) => {
const keys = Object.keys(row);
const missingColumns = columns.filter(column => !keys.includes(column));
if (missingColumns.length > 0) {
throw new google_gax_1.GoogleError([
`Row at index ${index} does not contain the correct number of columns.`,
`Missing columns: ${JSON.stringify(missingColumns)}`,
].join('\n\n'));
}
const values = columns.map(column => row[column]);
return codec_1.codec.convertToListValue(values);
});
const mutation = {
[method]: { table, columns, values },
};
return mutation;
}
/**
* Builds a delete mutation.
*
* @param {string} table - The name of the table.
* @param {Key[]} keys - The keys for the rows to delete.
* @returns {spannerClient.spanner.v1.Mutation} - The formatted delete mutation.
*/
function buildDeleteMutation(table, keys) {
const keySet = {
keys: (0, helper_1.toArray)(keys).map(codec_1.codec.convertToListValue),
};
const mutation = {
delete: { table, keySet },
};
return mutation;
}
/**
* MutationSet represent a set of changes to be applied atomically to a Cloud Spanner
* database with a {@link Transaction}.
* Mutations are used to insert, update, upsert(insert or update), replace, or
* delete rows within tables.
*
* Mutations are added to a {@link Transaction} and are not executed until the
* transaction is committed via {@link Transaction#commit}.
*
* If the transaction is rolled back or encounters an error, the mutations are
* discarded.
*
* @example
* ```
* const {Spanner, Mutation} = require('@google-cloud/spanner');
* const spanner = new Spanner();
*
* const instance = spanner.instance('my-instance');
* const database = instance.database('my-database');
*
* const mutations = new MutationSet();
* mutations.insert('Singers', {SingerId: '123', FirstName: 'David'});
* mutations.update('Singers', {SingerId: '123', FirstName: 'Marc'});
*
* try {
* database.writeAtLeastOnce(mutations, (err, res) => {
* console.log("RESPONSE: ", res);
* });
* } catch(err) {
* console.log("ERROR: ", err);
* }
* ```
*/
class MutationSet {
/**
* An array to store the mutations.
*/
_queuedMutations;
/**
* Creates a new Mutation object.
*/
constructor() {
this._queuedMutations = [];
}
/**
* Adds an insert operation to the mutation set.
* @param {string} table. The name of the table to insert into.
* @param {object|object[]} rows. A single row object or an array of row objects to insert.
*/
insert(table, rows) {
this._queuedMutations.push(buildMutation('insert', table, rows));
}
/**
* Adds an update operation to the mutation set.
* @param {string} table. The name of the table to update.
* @param {object|object[]} rows. A single row object or an array of row objects to update.
* Each row object must contain the primary key values to indentify the row to update.
*/
update(table, rows) {
this._queuedMutations.push(buildMutation('update', table, rows));
}
/**
* Adds an upsert operation to the mutation set.
* An upsert will insert a new row if it does not exist or update an existing row if it does.
* @param {string} table. The name of the table to upsert.
* @param {object|object[]} rows. A single row object or an array of row objects to upsert.
*/
upsert(table, rows) {
this._queuedMutations.push(buildMutation('insertOrUpdate', table, rows));
}
/**
* Adds a replace operation to the mutation set.
* A replace operation deletes the existing row (if it exists) and inserts the new row.
* @param {string} table. The name of the table to replace.
* @param {object|object[]} rows. A single row object or an array of row objects to replace.
*/
replace(table, rows) {
this._queuedMutations.push(buildMutation('replace', table, rows));
}
/**
* Adds a deleteRows operation to the mutation set.
* This operation deletes rows from the specified table based on their primary keys.
* @param {string} table. The name of the table to deleteRows from.
* @param {key[]} key. An array of key objects, each represeting the primary key of a row to delete.
*/
deleteRows(table, keys) {
this._queuedMutations.push(buildDeleteMutation(table, keys));
}
/**
* Returns the internal representation of the queued mutations as a protobuf message.
* @returns {spannerClient.spanner.v1.Mutation[]}. The protobuf message representing the mutations.
*/
proto() {
return this._queuedMutations;
}
}
exports.MutationSet = MutationSet;
/**
* A group of mutations to be committed together.
* Related mutations should be placed in a group.
*
* For example, two mutations inserting rows with the same primary
* key prefix in both parent and child tables are related.
*
* This object is created and returned from {@link Database#MutationGroup}.
*
* @example
* ```
* const {Spanner} = require('@google-cloud/spanner');
* const spanner = new Spanner();
*
* const instance = spanner.instance('my-instance');
* const database = instance.database('my-database');
*
* const mutationGroup = new MutationGroup();
* mutationGroup.insert('Singers', {SingerId: '123', FirstName: 'David'});
* mutationGroup.update('Singers', {SingerId: '123', FirstName: 'Marc'});
*
* database.batchWriteAtLeastOnce([mutationGroup], {})
* .on('error', console.error)
* .on('data', response => {
* console.log('response: ', response);
* })
* .on('end', () => {
* console.log('Request completed successfully');
* });
* ```
*/
class MutationGroup {
_proto;
constructor() {
this._proto =
new spannerClient.spanner.v1.BatchWriteRequest.MutationGroup();
}
insert(table, rows) {
this._proto.mutations.push(buildMutation('insert', table, rows));
}
update(table, rows) {
this._proto.mutations.push(buildMutation('update', table, rows));
}
upsert(table, rows) {
this._proto.mutations.push(buildMutation('insertOrUpdate', table, rows));
}
replace(table, rows) {
this._proto.mutations.push(buildMutation('replace', table, rows));
}
deleteRows(table, keys) {
this._proto.mutations.push(buildDeleteMutation(table, keys));
}
proto() {
return this._proto;
}
}
exports.MutationGroup = MutationGroup;
/**
* This type of transaction is used to execute a single Partitioned DML
* statement. Partitioned DML partitions the key space and runs the DML
* statement over each partition in parallel using separate, internal
* transactions that commit independently.
*
* Chances are, you'll never need to create a partitioned DML transaction
* directly, instead you'll want to use {@link Database#runPartitionedUpdate}.
*
* @class
* @extends Snapshot
*
* @see Database#runPartitionedUpdate
*/
class PartitionedDml extends Dml {
constructor(session, options = {}) {
super(session);
this._options = { partitionedDml: options };
}
/**
* Use option excludeTxnFromChangeStreams to exclude partitionedDml
* queries from being tracked in change streams.
*
* Enabling this options to true will effectively disable change stream tracking
* for a specified partitionedDml query, allowing write queries to operate
* without being included in change streams.
*/
excludeTxnFromChangeStreams() {
this._options.excludeTxnFromChangeStreams = true;
}
runUpdate(query, callback) {
return (0, instrument_1.startTrace)('PartitionedDml.runUpdate', {
...query,
...this._traceConfig,
}, span => {
super.runUpdate(query, (err, count) => {
if (err) {
(0, instrument_1.setSpanError)(span, err);
}
this.end();
span.end();
callback(err, count);
});
});
}
}
exports.PartitionedDml = PartitionedDml;
function isErrorAborted(err) {
return (err &&
typeof err === 'object' &&
'code' in err &&
err.code === google_gax_1.grpc.status.ABORTED);
}
/*! Developer Documentation
*
* All async methods (except for streams) return a Promise in the event
* that a callback is omitted.
*/
(0, promisify_1.promisifyAll)(PartitionedDml);
//# sourceMappingURL=transaction.js.map