Skip to content

Commit 06aab6e

Browse files
authored
feat(spanner): add support for batchWrite (googleapis#2054)
This PR contains Batch Write API implementation in Spanner.
1 parent 47f8928 commit 06aab6e

File tree

9 files changed

+708
-35
lines changed

9 files changed

+708
-35
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Samples are in the [`samples/`](https://github.com/googleapis/nodejs-spanner/tre
102102
| Backups-restore | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-restore.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/backups-restore.js,samples/README.md) |
103103
| Backups-update | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-update.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/backups-update.js,samples/README.md) |
104104
| Backups | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups.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/backups.js,samples/README.md) |
105+
| Batch Write | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/batch-write.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-write.js,samples/README.md) |
105106
| 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) |
106107
| 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) |
107108
| 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) |

samples/README.md

+18
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and automatic, synchronous replication for high availability.
2727
* [Backups-restore](#backups-restore)
2828
* [Backups-update](#backups-update)
2929
* [Backups](#backups)
30+
* [Batch Write](#batch-write)
3031
* [Batch](#batch)
3132
* [CRUD](#crud)
3233
* [Creates a new database with a specific default leader](#creates-a-new-database-with-a-specific-default-leader)
@@ -354,6 +355,23 @@ __Usage:__
354355

355356

356357

358+
### Batch Write
359+
360+
View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/batch-write.js).
361+
362+
[![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-write.js,samples/README.md)
363+
364+
__Usage:__
365+
366+
367+
`node batch-write.js <INSTANCE_ID> <DATABASE_ID> <PROJECT_ID>`
368+
369+
370+
-----
371+
372+
373+
374+
357375
### Batch
358376

359377
View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/batch.js).

samples/batch-write.js

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Copyright 2024 Google LLC
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
// sample-metadata:
17+
// title: Batch Write
18+
// usage: node batch-write.js <INSTANCE_ID> <DATABASE_ID> <PROJECT_ID>
19+
20+
'use strict';
21+
22+
async function main(
23+
instanceId = 'my-instance',
24+
databaseId = 'my-database',
25+
projectId = 'my-project-id'
26+
) {
27+
// [START spanner_batch_write_at_least_once]
28+
29+
// Imports the Google Cloud client library
30+
const {Spanner, MutationGroup} = require('@google-cloud/spanner');
31+
32+
/**
33+
* TODO(developer): Uncomment the following lines before running the sample.
34+
*/
35+
// const instanceId = 'my-instance';
36+
// const databaseId = 'my-database';
37+
// const projectId = 'my-project-id';
38+
39+
// Creates a client
40+
const spanner = new Spanner({
41+
projectId: projectId,
42+
});
43+
44+
// Gets a reference to a Cloud Spanner instance and database
45+
const instance = spanner.instance(instanceId);
46+
const database = instance.database(databaseId);
47+
48+
// Create Mutation Groups
49+
/**
50+
* Related mutations should be placed in a group, such as insert mutations for both a parent and a child row.
51+
* A group must contain related mutations.
52+
* Please see {@link https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.BatchWriteRequest.MutationGroup}
53+
* for more details and examples.
54+
*/
55+
const mutationGroup1 = new MutationGroup();
56+
mutationGroup1.insert('Singers', {
57+
SingerId: 1,
58+
FirstName: 'Scarlet',
59+
LastName: 'Terry',
60+
});
61+
62+
const mutationGroup2 = new MutationGroup();
63+
mutationGroup2.insert('Singers', {
64+
SingerId: 2,
65+
FirstName: 'Marc',
66+
});
67+
mutationGroup2.insert('Singers', {
68+
SingerId: 3,
69+
FirstName: 'Catalina',
70+
LastName: 'Smith',
71+
});
72+
mutationGroup2.insert('Albums', {
73+
AlbumId: 1,
74+
SingerId: 2,
75+
AlbumTitle: 'Total Junk',
76+
});
77+
mutationGroup2.insert('Albums', {
78+
AlbumId: 2,
79+
SingerId: 3,
80+
AlbumTitle: 'Go, Go, Go',
81+
});
82+
83+
const options = {
84+
transactionTag: 'batch-write-tag',
85+
};
86+
87+
try {
88+
database
89+
.batchWriteAtLeastOnce([mutationGroup1, mutationGroup2], options)
90+
.on('error', console.error)
91+
.on('data', response => {
92+
// Check the response code of each response to determine whether the mutation group(s) were applied successfully.
93+
if (response.status.code === 0) {
94+
console.log(
95+
`Mutation group indexes ${
96+
response.indexes
97+
}, have been applied with commit timestamp ${Spanner.timestamp(
98+
response.commitTimestamp
99+
).toJSON()}`
100+
);
101+
}
102+
// Mutation groups that fail to commit trigger a response with a non-zero status code.
103+
else {
104+
console.log(
105+
`Mutation group indexes ${response.indexes}, could not be applied with error code ${response.status.code}, and error message ${response.status.message}`
106+
);
107+
}
108+
})
109+
.on('end', () => {
110+
console.log('Request completed successfully');
111+
});
112+
} catch (err) {
113+
console.log(err);
114+
}
115+
// [END spanner_batch_write_at_least_once]
116+
}
117+
118+
process.on('unhandledRejection', err => {
119+
console.error(err.message);
120+
process.exitCode = 1;
121+
});
122+
123+
main(...process.argv.slice(2));

samples/system-test/spanner.test.js

+22
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const requestTagCommand = 'node request-tag.js';
4040
const timestampCmd = 'node timestamp.js';
4141
const structCmd = 'node struct.js';
4242
const dmlCmd = 'node dml.js';
43+
const batchWriteCmd = 'node batch-write.js';
4344
const datatypesCmd = 'node datatypes.js';
4445
const backupsCmd = 'node backups.js';
4546
const instanceCmd = 'node instance.js';
@@ -967,6 +968,27 @@ describe('Autogenerated Admin Clients', () => {
967968
assert.match(output, new RegExp('Virginia Watson'));
968969
});
969970

971+
// batch_write
972+
it('should perform CRUD operations using batch write', async () => {
973+
const output = execSync(
974+
`${batchWriteCmd} ${INSTANCE_ID} ${DATABASE_ID} ${PROJECT_ID}`
975+
).toString();
976+
977+
const successRegex =
978+
/Mutation group indexes [\d,]+ have been applied with commit timestamp \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/;
979+
const failureRegex =
980+
/Mutation group indexes [\d,]+, could not be applied with error code \d+, and error message .+/;
981+
982+
const successMatch = successRegex.test(output);
983+
const errorMatch = failureRegex.test(output);
984+
985+
if (successMatch || errorMatch) {
986+
assert.include(output, 'Request completed successfully');
987+
} else {
988+
assert.ifError(output);
989+
}
990+
});
991+
970992
// create_table_with_datatypes
971993
it('should create Venues example table with supported datatype columns', async () => {
972994
const output = execSync(

src/database.ts

+121
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ import {
6060
} from './session-pool';
6161
import {CreateTableCallback, CreateTableResponse, Table} from './table';
6262
import {
63+
BatchWriteOptions,
6364
ExecuteSqlRequest,
65+
MutationGroup,
6466
RunCallback,
6567
RunResponse,
6668
RunUpdateCallback,
@@ -3210,6 +3212,123 @@ class Database extends common.GrpcServiceObject {
32103212
}
32113213
}
32123214
}
3215+
3216+
/**
3217+
* Write a batch of mutations to Spanner.
3218+
*
3219+
* All mutations in a group are committed atomically. However, mutations across
3220+
* groups can be committed non-atomically in an unspecified order and thus, they
3221+
* must be independent of each other. Partial failure is possible, i.e., some groups
3222+
* may have been committed successfully, while some may have failed. The results of
3223+
* individual batches are streamed into the response as the batches are applied.
3224+
*
3225+
* batchWriteAtLeastOnce requests are not replay protected, meaning that each mutation group may
3226+
* be applied more than once. Replays of non-idempotent mutations may have undesirable
3227+
* effects. For example, replays of an insert mutation may produce an already exists
3228+
* error or if you use generated or commit timestamp-based keys, it may result in additional
3229+
* rows being added to the mutation's table. We recommend structuring your mutation groups to
3230+
* be idempotent to avoid this issue.
3231+
*
3232+
* @method Spanner#batchWriteAtLeastOnce
3233+
*
3234+
* @param {MutationGroup[]} [mutationGroups] The group of mutations to be applied.
3235+
* @param {BatchWriteOptions} [options] Options object for batch write request.
3236+
*
3237+
* @returns {ReadableStream} An object stream which emits
3238+
* {@link protos.google.spanner.v1.BatchWriteResponse|BatchWriteResponse}
3239+
* on 'data' event.
3240+
*
3241+
* @example
3242+
* ```
3243+
* const {Spanner} = require('@google-cloud/spanner');
3244+
* const spanner = new Spanner();
3245+
*
3246+
* const instance = spanner.instance('my-instance');
3247+
* const database = instance.database('my-database');
3248+
* const mutationGroup = new MutationGroup();
3249+
* mutationGroup.insert('Singers', {
3250+
* SingerId: '1',
3251+
* FirstName: 'Marc',
3252+
* LastName: 'Richards',
3253+
* });
3254+
*
3255+
* database.batchWriteAtLeastOnce([mutationGroup])
3256+
* .on('error', console.error)
3257+
* .on('data', response => {
3258+
* console.log('response: ', response);
3259+
* })
3260+
* .on('end', () => {
3261+
* console.log('Request completed successfully');
3262+
* });
3263+
*
3264+
* //-
3265+
* // If you anticipate many results, you can end a stream early to prevent
3266+
* // unnecessary processing and API requests.
3267+
* //-
3268+
* database.batchWriteAtLeastOnce()
3269+
* .on('data', response => {
3270+
* this.end();
3271+
* });
3272+
* ```
3273+
*/
3274+
batchWriteAtLeastOnce(
3275+
mutationGroups: MutationGroup[],
3276+
options?: BatchWriteOptions
3277+
): NodeJS.ReadableStream {
3278+
const proxyStream: Transform = through.obj();
3279+
3280+
this.pool_.getSession((err, session) => {
3281+
if (err) {
3282+
proxyStream.destroy(err);
3283+
return;
3284+
}
3285+
const gaxOpts = extend(true, {}, options?.gaxOptions);
3286+
const reqOpts = Object.assign(
3287+
{} as spannerClient.spanner.v1.BatchWriteRequest,
3288+
{
3289+
session: session!.formattedName_!,
3290+
mutationGroups: mutationGroups.map(mg => mg.proto()),
3291+
requestOptions: options?.requestOptions,
3292+
}
3293+
);
3294+
let dataReceived = false;
3295+
let dataStream = this.requestStream({
3296+
client: 'SpannerClient',
3297+
method: 'batchWrite',
3298+
reqOpts,
3299+
gaxOpts,
3300+
headers: this.resourceHeader_,
3301+
});
3302+
dataStream
3303+
.once('data', () => (dataReceived = true))
3304+
.once('error', err => {
3305+
if (
3306+
!dataReceived &&
3307+
isSessionNotFoundError(err as grpc.ServiceError)
3308+
) {
3309+
// If there's a 'Session not found' error and we have not yet received
3310+
// any data, we can safely retry the writes on a new session.
3311+
// Register the error on the session so the pool can discard it.
3312+
if (session) {
3313+
session.lastError = err as grpc.ServiceError;
3314+
}
3315+
// Remove the current data stream from the end user stream.
3316+
dataStream.unpipe(proxyStream);
3317+
dataStream.end();
3318+
// Create a new stream and add it to the end user stream.
3319+
dataStream = this.batchWriteAtLeastOnce(mutationGroups, options);
3320+
dataStream.pipe(proxyStream);
3321+
} else {
3322+
proxyStream.destroy(err);
3323+
}
3324+
})
3325+
.once('end', () => this.pool_.release(session!))
3326+
.pipe(proxyStream);
3327+
});
3328+
3329+
return proxyStream as NodeJS.ReadableStream;
3330+
}
3331+
32133332
/**
32143333
* Create a Session object.
32153334
*
@@ -3515,6 +3634,7 @@ class Database extends common.GrpcServiceObject {
35153634
promisifyAll(Database, {
35163635
exclude: [
35173636
'batchTransaction',
3637+
'batchWriteAtLeastOnce',
35183638
'getRestoreInfo',
35193639
'getState',
35203640
'getDatabaseDialect',
@@ -3536,6 +3656,7 @@ callbackifyAll(Database, {
35363656
'create',
35373657
'batchCreateSessions',
35383658
'batchTransaction',
3659+
'batchWriteAtLeastOnce',
35393660
'close',
35403661
'createBatchTransaction',
35413662
'createSession',

src/index.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,12 @@ import {
7070
import {Session} from './session';
7171
import {SessionPool} from './session-pool';
7272
import {Table} from './table';
73-
import {PartitionedDml, Snapshot, Transaction} from './transaction';
73+
import {
74+
MutationGroup,
75+
PartitionedDml,
76+
Snapshot,
77+
Transaction,
78+
} from './transaction';
7479
import grpcGcpModule = require('grpc-gcp');
7580
const grpcGcp = grpcGcpModule(grpc);
7681
import * as v1 from './v1';
@@ -2011,6 +2016,15 @@ export {Snapshot};
20112016
*/
20122017
export {Transaction};
20132018

2019+
/**
2020+
* {@link MutationGroup} class.
2021+
*
2022+
* @name Spanner.MutationGroup
2023+
* @see MutationGroup
2024+
* @type {Constructor}
2025+
*/
2026+
export {MutationGroup};
2027+
20142028
/**
20152029
* @type {object}
20162030
* @property {constructor} DatabaseAdminClient

0 commit comments

Comments
 (0)