Skip to content

Commit 868f30f

Browse files
authored
feat: specify isolation level per transaction (#3704)
* feat: add default_isolation_level connection property Add a `default_isolation_level` property for the Connection API. This property will be used by the JDBC driver and PGAdapter to set a default isolation level for all read/write transactions that are executed by a connection. Support for setting an isolation level for a single transaction will be added in a follow-up pull request. * feat: specify isolation level per transaction Add an option to specify the isolation level for a single transaction. This isolation level overrides the current default isolation level that has been set for the connection. This option only has an effect for read/write transactions. * chore: make a couple of test classes public Check if making a couple of test classes public fixes the weird native image build error. * build: register ClientSideStatementBeginExecutor for reflection
1 parent 68784ff commit 868f30f

16 files changed

+345
-37
lines changed

google-cloud-spanner/clirr-ignored-differences.xml

+12
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,18 @@
915915
<method>com.google.spanner.v1.TransactionOptions$IsolationLevel getDefaultIsolationLevel()</method>
916916
</difference>
917917

918+
<!-- Isolation level per transaction -->
919+
<difference>
920+
<differenceType>7012</differenceType>
921+
<className>com/google/cloud/spanner/connection/Connection</className>
922+
<method>void beginTransaction(com.google.spanner.v1.TransactionOptions$IsolationLevel)</method>
923+
</difference>
924+
<difference>
925+
<differenceType>7012</differenceType>
926+
<className>com/google/cloud/spanner/connection/Connection</className>
927+
<method>com.google.api.core.ApiFuture beginTransactionAsync(com.google.spanner.v1.TransactionOptions$IsolationLevel)</method>
928+
</difference>
929+
918930
<!-- Removed ConnectionOptions$ConnectionProperty in favor of the more generic ConnectionProperty class. -->
919931
<difference>
920932
<differenceType>8001</differenceType>

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java

+8-7
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ public static AbstractStatementParser getInstance(Dialect dialect) {
100100
}
101101
}
102102

103+
static final Set<String> ddlStatements =
104+
ImmutableSet.of("CREATE", "DROP", "ALTER", "ANALYZE", "GRANT", "REVOKE", "RENAME");
105+
static final Set<String> selectStatements =
106+
ImmutableSet.of("SELECT", "WITH", "SHOW", "FROM", "GRAPH");
107+
static final Set<String> SELECT_STATEMENTS_ALLOWING_PRECEDING_BRACKETS =
108+
ImmutableSet.of("SELECT", "FROM");
109+
static final Set<String> dmlStatements = ImmutableSet.of("INSERT", "UPDATE", "DELETE");
110+
103111
/*
104112
* The following fixed pre-parsed statements are used internally by the Connection API. These do
105113
* not need to be parsed using a specific dialect, as they are equal for all dialects, and
@@ -416,13 +424,6 @@ ClientSideStatement getClientSideStatement() {
416424
}
417425
}
418426

419-
static final Set<String> ddlStatements =
420-
ImmutableSet.of("CREATE", "DROP", "ALTER", "ANALYZE", "GRANT", "REVOKE", "RENAME");
421-
static final Set<String> selectStatements =
422-
ImmutableSet.of("SELECT", "WITH", "SHOW", "FROM", "GRAPH");
423-
static final Set<String> SELECT_STATEMENTS_ALLOWING_PRECEDING_BRACKETS =
424-
ImmutableSet.of("SELECT", "FROM");
425-
static final Set<String> dmlStatements = ImmutableSet.of("INSERT", "UPDATE", "DELETE");
426427
private final Set<ClientSideStatementImpl> statements;
427428

428429
/** The default maximum size of the statement cache in Mb. */
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spanner.connection;
18+
19+
import com.google.cloud.spanner.ErrorCode;
20+
import com.google.cloud.spanner.SpannerExceptionFactory;
21+
import com.google.cloud.spanner.connection.AbstractStatementParser.ParsedStatement;
22+
import com.google.cloud.spanner.connection.ClientSideStatementImpl.CompileException;
23+
import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.IsolationLevelConverter;
24+
import com.google.spanner.v1.TransactionOptions.IsolationLevel;
25+
import java.lang.reflect.Method;
26+
import java.util.regex.Matcher;
27+
28+
/** Executor for BEGIN TRANSACTION [ISOLATION LEVEL SERIALIZABLE|REPEATABLE READ] statements. */
29+
class ClientSideStatementBeginExecutor implements ClientSideStatementExecutor {
30+
private final ClientSideStatementImpl statement;
31+
private final Method method;
32+
private final IsolationLevelConverter converter;
33+
34+
ClientSideStatementBeginExecutor(ClientSideStatementImpl statement) throws CompileException {
35+
try {
36+
this.statement = statement;
37+
this.converter = new IsolationLevelConverter();
38+
this.method =
39+
ConnectionStatementExecutor.class.getDeclaredMethod(
40+
statement.getMethodName(), converter.getParameterClass());
41+
} catch (Exception e) {
42+
throw new CompileException(e, statement);
43+
}
44+
}
45+
46+
@Override
47+
public StatementResult execute(ConnectionStatementExecutor connection, ParsedStatement statement)
48+
throws Exception {
49+
return (StatementResult)
50+
method.invoke(connection, getParameterValue(statement.getSqlWithoutComments()));
51+
}
52+
53+
IsolationLevel getParameterValue(String sql) {
54+
Matcher matcher = statement.getPattern().matcher(sql);
55+
// Match the 'isolation level (serializable|repeatable read)' part.
56+
// Group 1 is the isolation level.
57+
if (matcher.find() && matcher.groupCount() >= 1) {
58+
String value = matcher.group(1);
59+
if (value != null) {
60+
// Convert the text to an isolation level enum.
61+
// This returns null if the string is not a valid isolation level value.
62+
IsolationLevel res = converter.convert(value.trim());
63+
if (res != null) {
64+
return res;
65+
}
66+
throw SpannerExceptionFactory.newSpannerException(
67+
ErrorCode.INVALID_ARGUMENT, String.format("Unknown isolation level: %s", value));
68+
}
69+
}
70+
return null;
71+
}
72+
}

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementValueConverters.java

+6-1
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ static class IsolationLevelConverter
394394
private final CaseInsensitiveEnumMap<TransactionOptions.IsolationLevel> values =
395395
new CaseInsensitiveEnumMap<>(TransactionOptions.IsolationLevel.class);
396396

397-
private IsolationLevelConverter() {}
397+
IsolationLevelConverter() {}
398398

399399
/** Constructor needed for reflection. */
400400
public IsolationLevelConverter(String allowedValues) {}
@@ -406,6 +406,11 @@ public Class<TransactionOptions.IsolationLevel> getParameterClass() {
406406

407407
@Override
408408
public TransactionOptions.IsolationLevel convert(String value) {
409+
if (value != null) {
410+
// This ensures that 'repeatable read' is translated to 'repeatable_read'. The text between
411+
// 'repeatable' and 'read' can be any number of valid whitespace characters.
412+
value = value.trim().replaceFirst("\\s+", "_");
413+
}
409414
return values.get(value);
410415
}
411416
}

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/Connection.java

+16-2
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,8 @@ public interface Connection extends AutoCloseable {
296296
void cancel();
297297

298298
/**
299-
* Begins a new transaction for this connection.
299+
* Begins a new transaction for this connection. The transaction will use the default isolation
300+
* level of this connection.
300301
*
301302
* <ul>
302303
* <li>Calling this method on a connection that has no transaction and that is
@@ -313,9 +314,16 @@ public interface Connection extends AutoCloseable {
313314
*/
314315
void beginTransaction();
315316

317+
/**
318+
* Same as {@link #beginTransaction()}, but this transaction will use the given isolation level,
319+
* instead of the default isolation level of this connection.
320+
*/
321+
void beginTransaction(IsolationLevel isolationLevel);
322+
316323
/**
317324
* Begins a new transaction for this connection. This method is guaranteed to be non-blocking. The
318-
* returned {@link ApiFuture} will be done when the transaction has been initialized.
325+
* returned {@link ApiFuture} will be done when the transaction has been initialized. The
326+
* transaction will use the default isolation level of this connection.
319327
*
320328
* <ul>
321329
* <li>Calling this method on a connection that has no transaction and that is
@@ -332,6 +340,12 @@ public interface Connection extends AutoCloseable {
332340
*/
333341
ApiFuture<Void> beginTransactionAsync();
334342

343+
/**
344+
* Same as {@link #beginTransactionAsync()}, but this transaction will use the given isolation
345+
* level, instead of the default isolation level of this connection.
346+
*/
347+
ApiFuture<Void> beginTransactionAsync(IsolationLevel isolationLevel);
348+
335349
/**
336350
* Sets the transaction mode to use for current transaction. This method may only be called when
337351
* in a transaction, and before the transaction is actually started, i.e. before any statements

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionImpl.java

+28-16
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ static UnitOfWorkType of(TransactionMode transactionMode) {
285285

286286
// The following properties are not 'normal' connection properties, but transient properties that
287287
// are automatically reset after executing a transaction or statement.
288+
private IsolationLevel transactionIsolationLevel;
288289
private String transactionTag;
289290
private String statementTag;
290291
private boolean excludeTxnFromChangeStreams;
@@ -336,7 +337,7 @@ && getDialect() == Dialect.POSTGRESQL
336337
: Type.NON_TRANSACTIONAL));
337338

338339
// (Re)set the state of the connection to the default.
339-
setDefaultTransactionOptions();
340+
setDefaultTransactionOptions(getDefaultIsolationLevel());
340341
}
341342

342343
/** Constructor only for test purposes. */
@@ -370,7 +371,7 @@ && getDialect() == Dialect.POSTGRESQL
370371
setReadOnly(options.isReadOnly());
371372
setAutocommit(options.isAutocommit());
372373
setReturnCommitStats(options.isReturnCommitStats());
373-
setDefaultTransactionOptions();
374+
setDefaultTransactionOptions(getDefaultIsolationLevel());
374375
}
375376

376377
@Override
@@ -505,7 +506,7 @@ private void reset(Context context, boolean inTransaction) {
505506
this.protoDescriptorsFilePath = null;
506507

507508
if (!isTransactionStarted()) {
508-
setDefaultTransactionOptions();
509+
setDefaultTransactionOptions(getDefaultIsolationLevel());
509510
}
510511
}
511512

@@ -595,7 +596,7 @@ public void setAutocommit(boolean autocommit) {
595596
// middle of a transaction.
596597
this.connectionState.commit();
597598
}
598-
clearLastTransactionAndSetDefaultTransactionOptions();
599+
clearLastTransactionAndSetDefaultTransactionOptions(getDefaultIsolationLevel());
599600
// Reset the readOnlyStaleness value if it is no longer compatible with the new autocommit
600601
// value.
601602
if (!autocommit) {
@@ -629,7 +630,7 @@ public void setReadOnly(boolean readOnly) {
629630
ConnectionPreconditions.checkState(
630631
!transactionBeginMarked, "Cannot set read-only when a transaction has begun");
631632
setConnectionPropertyValue(READONLY, readOnly);
632-
clearLastTransactionAndSetDefaultTransactionOptions();
633+
clearLastTransactionAndSetDefaultTransactionOptions(getDefaultIsolationLevel());
633634
}
634635

635636
@Override
@@ -647,7 +648,7 @@ public void setDefaultIsolationLevel(IsolationLevel isolationLevel) {
647648
!isTransactionStarted(),
648649
"Cannot set default isolation level while a transaction is active");
649650
setConnectionPropertyValue(DEFAULT_ISOLATION_LEVEL, isolationLevel);
650-
clearLastTransactionAndSetDefaultTransactionOptions();
651+
clearLastTransactionAndSetDefaultTransactionOptions(isolationLevel);
651652
}
652653

653654
@Override
@@ -656,8 +657,8 @@ public IsolationLevel getDefaultIsolationLevel() {
656657
return getConnectionPropertyValue(DEFAULT_ISOLATION_LEVEL);
657658
}
658659

659-
private void clearLastTransactionAndSetDefaultTransactionOptions() {
660-
setDefaultTransactionOptions();
660+
private void clearLastTransactionAndSetDefaultTransactionOptions(IsolationLevel isolationLevel) {
661+
setDefaultTransactionOptions(isolationLevel);
661662
this.currentUnitOfWork = null;
662663
}
663664

@@ -1139,13 +1140,14 @@ public boolean isKeepTransactionAlive() {
11391140
}
11401141

11411142
/** Resets this connection to its default transaction options. */
1142-
private void setDefaultTransactionOptions() {
1143+
private void setDefaultTransactionOptions(IsolationLevel isolationLevel) {
11431144
if (transactionStack.isEmpty()) {
11441145
unitOfWorkType =
11451146
isReadOnly()
11461147
? UnitOfWorkType.READ_ONLY_TRANSACTION
11471148
: UnitOfWorkType.READ_WRITE_TRANSACTION;
11481149
batchMode = BatchMode.NONE;
1150+
transactionIsolationLevel = isolationLevel;
11491151
transactionTag = null;
11501152
excludeTxnFromChangeStreams = false;
11511153
} else {
@@ -1155,11 +1157,21 @@ private void setDefaultTransactionOptions() {
11551157

11561158
@Override
11571159
public void beginTransaction() {
1158-
get(beginTransactionAsync());
1160+
get(beginTransactionAsync(getConnectionPropertyValue(DEFAULT_ISOLATION_LEVEL)));
1161+
}
1162+
1163+
@Override
1164+
public void beginTransaction(IsolationLevel isolationLevel) {
1165+
get(beginTransactionAsync(isolationLevel));
11591166
}
11601167

11611168
@Override
11621169
public ApiFuture<Void> beginTransactionAsync() {
1170+
return beginTransactionAsync(getConnectionPropertyValue(DEFAULT_ISOLATION_LEVEL));
1171+
}
1172+
1173+
@Override
1174+
public ApiFuture<Void> beginTransactionAsync(IsolationLevel isolationLevel) {
11631175
ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG);
11641176
ConnectionPreconditions.checkState(
11651177
!isBatchActive(), "This connection has an active batch and cannot begin a transaction");
@@ -1169,7 +1181,7 @@ public ApiFuture<Void> beginTransactionAsync() {
11691181
ConnectionPreconditions.checkState(!transactionBeginMarked, "A transaction has already begun");
11701182

11711183
transactionBeginMarked = true;
1172-
clearLastTransactionAndSetDefaultTransactionOptions();
1184+
clearLastTransactionAndSetDefaultTransactionOptions(isolationLevel);
11731185
if (isAutocommit()) {
11741186
inTransaction = true;
11751187
}
@@ -1284,7 +1296,7 @@ private ApiFuture<Void> endCurrentTransactionAsync(
12841296
if (isAutocommit()) {
12851297
inTransaction = false;
12861298
}
1287-
setDefaultTransactionOptions();
1299+
setDefaultTransactionOptions(getDefaultIsolationLevel());
12881300
}
12891301
return res;
12901302
}
@@ -2196,7 +2208,7 @@ UnitOfWork createNewUnitOfWork(
21962208
.build();
21972209
if (!isInternalMetadataQuery && !forceSingleUse) {
21982210
// Reset the transaction options after starting a single-use transaction.
2199-
setDefaultTransactionOptions();
2211+
setDefaultTransactionOptions(getDefaultIsolationLevel());
22002212
}
22012213
return singleUseTransaction;
22022214
} else {
@@ -2217,7 +2229,7 @@ UnitOfWork createNewUnitOfWork(
22172229
.setUsesEmulator(options.usesEmulator())
22182230
.setUseAutoSavepointsForEmulator(options.useAutoSavepointsForEmulator())
22192231
.setDatabaseClient(dbClient)
2220-
.setIsolationLevel(getConnectionPropertyValue(DEFAULT_ISOLATION_LEVEL))
2232+
.setIsolationLevel(transactionIsolationLevel)
22212233
.setDelayTransactionStartUntilFirstWrite(
22222234
getConnectionPropertyValue(DELAY_TRANSACTION_START_UNTIL_FIRST_WRITE))
22232235
.setKeepTransactionAlive(getConnectionPropertyValue(KEEP_TRANSACTION_ALIVE))
@@ -2401,7 +2413,7 @@ public ApiFuture<long[]> runBatchAsync() {
24012413
this.protoDescriptorsFilePath = null;
24022414
}
24032415
this.batchMode = BatchMode.NONE;
2404-
setDefaultTransactionOptions();
2416+
setDefaultTransactionOptions(getDefaultIsolationLevel());
24052417
}
24062418
}
24072419

@@ -2415,7 +2427,7 @@ public void abortBatch() {
24152427
}
24162428
} finally {
24172429
this.batchMode = BatchMode.NONE;
2418-
setDefaultTransactionOptions();
2430+
setDefaultTransactionOptions(getDefaultIsolationLevel());
24192431
}
24202432
}
24212433

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutor.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.google.cloud.spanner.TimestampBound;
2222
import com.google.cloud.spanner.connection.PgTransactionMode.IsolationLevel;
2323
import com.google.spanner.v1.DirectedReadOptions;
24+
import com.google.spanner.v1.TransactionOptions;
2425
import java.time.Duration;
2526

2627
/**
@@ -107,7 +108,7 @@ StatementResult statementSetDelayTransactionStartUntilFirstWrite(
107108

108109
StatementResult statementShowExcludeTxnFromChangeStreams();
109110

110-
StatementResult statementBeginTransaction();
111+
StatementResult statementBeginTransaction(TransactionOptions.IsolationLevel isolationLevel);
111112

112113
StatementResult statementBeginPgTransaction(PgTransactionMode transactionMode);
113114

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutorImpl.java

+8-2
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
import com.google.spanner.v1.PlanNode;
113113
import com.google.spanner.v1.QueryPlan;
114114
import com.google.spanner.v1.RequestOptions;
115+
import com.google.spanner.v1.TransactionOptions;
115116
import java.time.Duration;
116117
import java.util.ArrayList;
117118
import java.util.Collections;
@@ -443,8 +444,13 @@ public StatementResult statementShowExcludeTxnFromChangeStreams() {
443444
}
444445

445446
@Override
446-
public StatementResult statementBeginTransaction() {
447-
getConnection().beginTransaction();
447+
public StatementResult statementBeginTransaction(
448+
TransactionOptions.IsolationLevel isolationLevel) {
449+
if (isolationLevel != null) {
450+
getConnection().beginTransaction(isolationLevel);
451+
} else {
452+
getConnection().beginTransaction();
453+
}
448454
return noResult(BEGIN);
449455
}
450456

0 commit comments

Comments
 (0)