Skip to content

Commit 854c1ee

Browse files
committed
feat(spanner): add support for snapshot isolation
1 parent 4824823 commit 854c1ee

File tree

8 files changed

+122
-4
lines changed

8 files changed

+122
-4
lines changed

src/database.ts

+29-4
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,10 @@ export interface RestoreOptions {
316316
gaxOptions?: CallOptions;
317317
}
318318

319+
export interface WriteAtLeastOnceOptions extends CallOptions {
320+
isolationLevel?: spannerClient.spanner.v1.TransactionOptions.IsolationLevel;
321+
}
322+
319323
/**
320324
* Create a Database object to interact with a Cloud Spanner database.
321325
*
@@ -3252,6 +3256,8 @@ class Database extends common.GrpcServiceObject {
32523256
? (optionsOrRunFn as RunTransactionOptions)
32533257
: {};
32543258

3259+
const defaultTransactionOptions = this._getSpanner().defaultTransactionOptions;
3260+
32553261
startTrace('Database.runTransaction', this._traceConfig, span => {
32563262
this.pool_.getSession((err, session?, transaction?) => {
32573263
if (err) {
@@ -3280,6 +3286,11 @@ class Database extends common.GrpcServiceObject {
32803286
if (options.excludeTxnFromChangeStreams) {
32813287
transaction!.excludeTxnFromChangeStreams();
32823288
}
3289+
if (options.isolationLevel) {
3290+
transaction!.setIsolationLevel(options.isolationLevel);
3291+
} else if (defaultTransactionOptions) {
3292+
transaction!.setIsolationLevel(defaultTransactionOptions.isolationLevel);
3293+
}
32833294

32843295
const release = () => {
32853296
this.pool_.release(session!);
@@ -3390,6 +3401,8 @@ class Database extends common.GrpcServiceObject {
33903401
? (optionsOrRunFn as RunTransactionOptions)
33913402
: {};
33923403

3404+
const defaultTransactionOptions = this._getSpanner().defaultTransactionOptions;
3405+
33933406
let sessionId = '';
33943407
const getSession = this.pool_.getSession.bind(this.pool_);
33953408
return startTrace(
@@ -3412,6 +3425,11 @@ class Database extends common.GrpcServiceObject {
34123425
if (options.excludeTxnFromChangeStreams) {
34133426
transaction.excludeTxnFromChangeStreams();
34143427
}
3428+
if (options.isolationLevel) {
3429+
transaction!.setIsolationLevel(options.isolationLevel);
3430+
} else if (defaultTransactionOptions) {
3431+
transaction!.setIsolationLevel(defaultTransactionOptions.isolationLevel);
3432+
}
34153433
sessionId = session?.id;
34163434
span.addEvent('Using Session', {'session.id': sessionId});
34173435
const runner = new AsyncTransactionRunner<T>(
@@ -3638,17 +3656,17 @@ class Database extends common.GrpcServiceObject {
36383656
writeAtLeastOnce(mutations: MutationSet): Promise<CommitResponse>;
36393657
writeAtLeastOnce(
36403658
mutations: MutationSet,
3641-
options: CallOptions
3659+
options: WriteAtLeastOnceOptions
36423660
): Promise<CommitResponse>;
36433661
writeAtLeastOnce(mutations: MutationSet, callback: CommitCallback): void;
36443662
writeAtLeastOnce(
36453663
mutations: MutationSet,
3646-
options: CallOptions,
3664+
options: WriteAtLeastOnceOptions,
36473665
callback: CommitCallback
36483666
): void;
36493667
writeAtLeastOnce(
36503668
mutations: MutationSet,
3651-
optionsOrCallback?: CallOptions | CommitCallback,
3669+
optionsOrCallback?: WriteAtLeastOnceOptions | CommitCallback,
36523670
callback?: CommitCallback
36533671
): void | Promise<CommitResponse> {
36543672
const cb =
@@ -3657,9 +3675,11 @@ class Database extends common.GrpcServiceObject {
36573675
: callback;
36583676
const options =
36593677
typeof optionsOrCallback === 'object' && optionsOrCallback
3660-
? (optionsOrCallback as CallOptions)
3678+
? (optionsOrCallback as WriteAtLeastOnceOptions)
36613679
: {};
36623680

3681+
const defaultTransactionOptions = this._getSpanner().defaultTransactionOptions;
3682+
36633683
return startTrace('Database.writeAtLeastOnce', this._traceConfig, span => {
36643684
this.sessionFactory_.getSession((err, session?, transaction?) => {
36653685
if (
@@ -3683,6 +3703,11 @@ class Database extends common.GrpcServiceObject {
36833703
span.addEvent('Using Session', {'session.id': session?.id});
36843704
this._releaseOnEnd(session!, transaction!, span);
36853705
try {
3706+
if (options.isolationLevel) {
3707+
transaction!.setIsolationLevel(options.isolationLevel);
3708+
} else if (defaultTransactionOptions) {
3709+
transaction!.setIsolationLevel(defaultTransactionOptions.isolationLevel);
3710+
}
36863711
transaction?.setQueuedMutations(mutations.proto());
36873712
return transaction?.commit(options, (err, resp) => {
36883713
if (err) {

src/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export interface SpannerOptions extends GrpcClientOptions {
145145
sslCreds?: grpc.ChannelCredentials;
146146
routeToLeaderEnabled?: boolean;
147147
directedReadOptions?: google.spanner.v1.IDirectedReadOptions | null;
148+
defaultTransactionOptions?: google.spanner.v1.ITransactionOptions | null;
148149
observabilityOptions?: ObservabilityOptions;
149150
}
150151
export interface RequestConfig {
@@ -247,6 +248,7 @@ class Spanner extends GrpcService {
247248
commonHeaders_: {[k: string]: string};
248249
routeToLeaderEnabled = true;
249250
directedReadOptions: google.spanner.v1.IDirectedReadOptions | null;
251+
defaultTransactionOptions: google.spanner.v1.ITransactionOptions | null;
250252
_observabilityOptions: ObservabilityOptions | undefined;
251253

252254
/**
@@ -331,6 +333,11 @@ class Spanner extends GrpcService {
331333
: null;
332334
delete options.directedReadOptions;
333335

336+
const defaultTransactionOptions = options.defaultTransactionOptions
337+
? options.defaultTransactionOptions
338+
: null;
339+
delete options.defaultTransactionOptions;
340+
334341
const emulatorHost = Spanner.getSpannerEmulatorHost();
335342
if (
336343
emulatorHost &&
@@ -371,6 +378,7 @@ class Spanner extends GrpcService {
371378
this.projectIdReplaced_ = false;
372379
this.projectFormattedName_ = 'projects/' + this.projectId;
373380
this.directedReadOptions = directedReadOptions;
381+
this.defaultTransactionOptions = defaultTransactionOptions;
374382
this._observabilityOptions = options.observabilityOptions;
375383
this.commonHeaders_ = getCommonHeaders(
376384
this.projectFormattedName_,

src/table.ts

+6
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
setSpanError,
3838
traceConfig,
3939
} from './instrument';
40+
import {protos} from '.';
4041

4142
export type Key = string | string[];
4243

@@ -53,6 +54,7 @@ export type DropTableCallback = UpdateSchemaCallback;
5354
interface MutateRowsOptions extends CommitOptions {
5455
requestOptions?: Omit<IRequestOptions, 'requestTag'>;
5556
excludeTxnFromChangeStreams?: boolean;
57+
isolationLevel?: protos.google.spanner.v1.TransactionOptions.IsolationLevel;
5658
}
5759

5860
export type DeleteRowsCallback = CommitCallback;
@@ -1100,10 +1102,14 @@ class Table {
11001102
? options.excludeTxnFromChangeStreams
11011103
: false;
11021104

1105+
const isolationLevel =
1106+
'isolationLevel' in options ? options.isolationLevel : undefined;
1107+
11031108
this.database.runTransaction(
11041109
{
11051110
requestOptions: requestOptions,
11061111
excludeTxnFromChangeStreams: excludeTxnFromChangeStreams,
1112+
isolationLevel: isolationLevel,
11071113
},
11081114
(err, transaction) => {
11091115
if (err) {

src/transaction-runner.ts

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {isSessionNotFoundError} from './session-pool';
2626
import {Database} from './database';
2727
import {google} from '../protos/protos';
2828
import IRequestOptions = google.spanner.v1.IRequestOptions;
29+
import {protos} from '.';
2930

3031
// eslint-disable-next-line @typescript-eslint/no-var-requires
3132
const jsonProtos = require('../protos/protos.json');
@@ -46,6 +47,7 @@ export interface RunTransactionOptions {
4647
requestOptions?: Pick<IRequestOptions, 'transactionTag'>;
4748
optimisticLock?: boolean;
4849
excludeTxnFromChangeStreams?: boolean;
50+
isolationLevel?: protos.google.spanner.v1.TransactionOptions.IsolationLevel;
4951
}
5052

5153
/**

src/transaction.ts

+7
Original file line numberDiff line numberDiff line change
@@ -2697,6 +2697,13 @@ export class Transaction extends Dml {
26972697
excludeTxnFromChangeStreams(): void {
26982698
this._options.excludeTxnFromChangeStreams = true;
26992699
}
2700+
2701+
/**
2702+
* Use option isolationLevel to add the isolation level in the transaction.
2703+
*/
2704+
setIsolationLevel(isolationLevel: any): void {
2705+
this._options.isolationLevel = isolationLevel;
2706+
}
27002707
}
27012708

27022709
/*! Developer Documentation

test/database.ts

+29
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ class FakeTransaction extends EventEmitter {
177177
setQueuedMutations(mutation) {
178178
this._queuedMutations = mutation;
179179
}
180+
setIsolationLevel(isolationLevel) {
181+
this._options.isolationLevel = isolationLevel;
182+
}
180183
commit(
181184
options?: CommitOptions,
182185
callback?: CommitCallback
@@ -3166,6 +3169,19 @@ describe('Database', () => {
31663169
assert.strictEqual(options, fakeOptions);
31673170
});
31683171

3172+
it('should optionally accept runner `option` isolationLevel', async () => {
3173+
const fakeOptions = {
3174+
isolationLevel:
3175+
protos.google.spanner.v1.TransactionOptions.IsolationLevel
3176+
.REPEATABLE_READ,
3177+
};
3178+
3179+
await database.runTransaction(fakeOptions, assert.ifError);
3180+
3181+
const options = fakeTransactionRunner.calledWith_[3];
3182+
assert.strictEqual(options, fakeOptions);
3183+
});
3184+
31693185
it('should release the session when finished', done => {
31703186
const releaseStub = (
31713187
sandbox.stub(pool, 'release') as sinon.SinonStub
@@ -3237,6 +3253,19 @@ describe('Database', () => {
32373253
assert.strictEqual(options, fakeOptions);
32383254
});
32393255

3256+
it('should optionally accept runner `option` isolationLevel', async () => {
3257+
const fakeOptions = {
3258+
isolationLevel:
3259+
protos.google.spanner.v1.TransactionOptions.IsolationLevel
3260+
.REPEATABLE_READ,
3261+
};
3262+
3263+
await database.runTransactionAsync(fakeOptions, assert.ifError);
3264+
3265+
const options = fakeAsyncTransactionRunner.calledWith_[3];
3266+
assert.strictEqual(options, fakeOptions);
3267+
});
3268+
32403269
it('should return the runners resolved value', async () => {
32413270
const fakeValue = {};
32423271

test/index.ts

+13
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,19 @@ describe('Spanner', () => {
327327
assert.strictEqual(spanner.directedReadOptions, fakeDirectedReadOptions);
328328
});
329329

330+
it('should optionally accept defaultTxnOptions', () => {
331+
const fakeDefaultTxnOptions = {
332+
isolationLevel:
333+
protos.google.spanner.v1.TransactionOptions.IsolationLevel
334+
.REPEATABLE_READ,
335+
};
336+
337+
const spanner = new Spanner({
338+
defaultTxnOptions: fakeDefaultTxnOptions,
339+
});
340+
assert.strictEqual(spanner.defaultTxnOptions, fakeDefaultTxnOptions);
341+
});
342+
330343
it('should set projectFormattedName_', () => {
331344
assert.strictEqual(
332345
spanner.projectFormattedName_,

test/spanner.ts

+28
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
Database,
2424
Instance,
2525
MutationSet,
26+
protos,
2627
SessionPool,
2728
Snapshot,
2829
Spanner,
@@ -3575,6 +3576,33 @@ describe('Spanner with mock server', () => {
35753576
assert.strictEqual(commitRequest.mutations.length, 2);
35763577
});
35773578

3579+
it('should apply blind writes only once with isolationLevel option', async () => {
3580+
const database = newTestDatabase();
3581+
const mutations = new MutationSet();
3582+
mutations.upsert('Singers', {
3583+
SingerId: 1,
3584+
FirstName: 'Marc',
3585+
LastName: 'Terry',
3586+
});
3587+
mutations.upsert('Singers', {
3588+
SingerId: 2,
3589+
FirstName: 'Scarlet',
3590+
});
3591+
await database.writeAtLeastOnce(mutations, {
3592+
isolationLevel:
3593+
protos.google.spanner.v1.TransactionOptions.IsolationLevel
3594+
.REPEATABLE_READ,
3595+
});
3596+
await database.close();
3597+
const request = spannerMock.getRequests().find(val => {
3598+
return (val as v1.CommitRequest).singleUseTransaction?.isolationLevel;
3599+
}) as v1.CommitRequest;
3600+
assert.strictEqual(
3601+
request.singleUseTransaction?.isolationLevel,
3602+
'REPEATABLE_READ'
3603+
);
3604+
});
3605+
35783606
it('should apply blind writes only once with excludeTxnFromChangeStreams option', async () => {
35793607
const database = newTestDatabase();
35803608
await database.runTransactionAsync(

0 commit comments

Comments
 (0)