Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1156,4 +1156,10 @@
<className>com/google/cloud/spanner/spi/v1/ChannelFinder</className>
<method>* findServer(*)</method>
</difference>
<!-- Database default isolation level -->
<difference>
<differenceType>7012</differenceType>
<className>com/google/cloud/spanner/DatabaseClient</className>
<method>com.google.spanner.v1.TransactionOptions$IsolationLevel getDatabaseDefaultIsolationLevel()</method>
</difference>
</differences>
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.google.cloud.spanner;

import com.google.api.core.InternalApi;
Comment thread
rayudu3745 marked this conversation as resolved.
Resolved
Comment thread
rayudu3745 marked this conversation as resolved.
Resolved
import com.google.api.gax.rpc.ServerStream;
import com.google.cloud.Timestamp;
import com.google.cloud.spanner.Options.ReadOnlyTransactionOption;
Comment thread
rayudu3745 marked this conversation as resolved.
Resolved
Expand All @@ -42,6 +43,23 @@ default Dialect getDialect() {
throw new UnsupportedOperationException("method should be overwritten");
}

/**
* Returns the default transaction isolation level that is configured for the database with the
* {@code default_transaction_isolation} database option. Returns {@link
* IsolationLevel#ISOLATION_LEVEL_UNSPECIFIED} if the option has not been set, and {@link
* IsolationLevel#UNRECOGNIZED} if the option is set to a value that is not known to this version
* of the client.
*
* <p>The option is read once, together with the dialect of the database, and is cached for the
* lifetime of this client. Changes to the option after it has been read are not reflected.
*
* @return the default transaction isolation level configured for the database.
*/
@InternalApi
default IsolationLevel getDatabaseDefaultIsolationLevel() {
throw new UnsupportedOperationException("method should be overwritten");
}

/**
* Returns the {@link DatabaseRole} used by the client connection. The database role that is used
* determines the access permissions that a connection has. This can for example be used to create
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.spanner.v1.BatchWriteResponse;
import com.google.spanner.v1.TransactionOptions.IsolationLevel;
import io.opentelemetry.api.common.Attributes;
import java.util.HashMap;
import java.util.Map;
Expand Down Expand Up @@ -80,6 +81,11 @@ public Dialect getDialect() {
return this.multiplexedSessionDatabaseClient.getDialect();
}

@Override
public IsolationLevel getDatabaseDefaultIsolationLevel() {
return this.multiplexedSessionDatabaseClient.getDatabaseDefaultIsolationLevel();
}

private final AbstractLazyInitializer<StatementFactory> statementFactorySupplier =
new AbstractLazyInitializer<StatementFactory>() {
@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.cloud.spanner;

import com.google.spanner.v1.TransactionOptions.IsolationLevel;
import java.util.Locale;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/** Database options that are read from {@code information_schema.database_options}. */
@NullMarked
final class DatabaseMetadata {
static final String DATABASE_DIALECT_OPTION = "database_dialect";
static final String DEFAULT_TRANSACTION_ISOLATION_OPTION = "default_transaction_isolation";

// The values of default_transaction_isolation. Spanner accepts any case when the option is set,
// and stores and returns these lower-case values for both GoogleSQL and PostgreSQL databases.
static final String SERIALIZABLE_VALUE = "serializable";
static final String REPEATABLE_READ_VALUE = "repeatable read";

/** Reads all the options in {@link DatabaseMetadata} with a single query. */
static final Statement STATEMENT =
Statement.of(
String.format(
"select option_name, option_value from information_schema.database_options "
+ "where option_name in ('%s', '%s')",
DATABASE_DIALECT_OPTION, DEFAULT_TRANSACTION_ISOLATION_OPTION));

private final String dialect;
private final IsolationLevel defaultIsolationLevel;

private DatabaseMetadata(String dialect, IsolationLevel defaultIsolationLevel) {
this.dialect = dialect;
this.defaultIsolationLevel = defaultIsolationLevel;
}

/**
* Creates a {@link DatabaseMetadata} from the result of {@link #STATEMENT}. Options that are not
* in the result have not been set.
*/
static DatabaseMetadata fromResultSet(ResultSet resultSet) {
String dialect = Dialect.GOOGLE_STANDARD_SQL.name();
IsolationLevel defaultIsolationLevel = IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED;
while (resultSet.next()) {
switch (resultSet.getString(0)) {
case DATABASE_DIALECT_OPTION:
dialect = resultSet.getString(1);
break;
case DEFAULT_TRANSACTION_ISOLATION_OPTION:
defaultIsolationLevel =
parseIsolationLevel(resultSet.isNull(1) ? null : resultSet.getString(1));
break;
default:
break;
}
}
return new DatabaseMetadata(dialect, defaultIsolationLevel);
}

/**
* Parses the value of the {@code default_transaction_isolation} option. {@code null} means that
* the option has not been set and returns {@link IsolationLevel#ISOLATION_LEVEL_UNSPECIFIED}.
* Values that are not known to this version of the client return {@link
* IsolationLevel#UNRECOGNIZED}.
*/
private static IsolationLevel parseIsolationLevel(@Nullable String optionValue) {
if (optionValue == null) {
return IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED;
}
switch (optionValue.toLowerCase(Locale.ROOT)) {
case SERIALIZABLE_VALUE:
return IsolationLevel.SERIALIZABLE;
case REPEATABLE_READ_VALUE:
return IsolationLevel.REPEATABLE_READ;
default:
return IsolationLevel.UNRECOGNIZED;
}
}

Dialect getDialect() {
return Dialect.fromName(dialect);
}

IsolationLevel getDefaultIsolationLevel() {
return defaultIsolationLevel;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import com.google.common.base.Preconditions;
import com.google.common.util.concurrent.Futures;
import com.google.spanner.v1.BatchWriteResponse;
import com.google.spanner.v1.TransactionOptions.IsolationLevel;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
Expand Down Expand Up @@ -68,14 +69,6 @@ final class MultiplexedSessionDatabaseClient extends AbstractMultiplexedSessionD
*/
private static final int MAX_INITIAL_CREATE_SESSION_ATTEMPTS = 10;

@VisibleForTesting
static final Statement DETERMINE_DIALECT_STATEMENT =
Statement.newBuilder(
"select option_value "
+ "from information_schema.database_options "
+ "where option_name='database_dialect'")
.build();

/**
* Represents a single transaction on a multiplexed session. This can be both a single-use or
* multi-use transaction, and both read/write or read-only transaction. This can be compared to a
Expand Down Expand Up @@ -534,17 +527,18 @@ private int getSingleUseChannelHint() {
}
}

private final AbstractLazyInitializer<Dialect> dialectSupplier =
new AbstractLazyInitializer<Dialect>() {
/**
* Reads the {@link DatabaseMetadata} with a single query the first time that it is needed, and
* caches it for the lifetime of this client. The metadata is not refreshed when the multiplexed
* session is replaced.
*/
private final AbstractLazyInitializer<DatabaseMetadata> databaseMetadataSupplier =
new AbstractLazyInitializer<DatabaseMetadata>() {
@Override
protected Dialect initialize() {
try (ResultSet dialectResultSet = singleUse().executeQuery(DETERMINE_DIALECT_STATEMENT)) {
if (dialectResultSet.next()) {
return Dialect.fromName(dialectResultSet.getString(0));
}
protected DatabaseMetadata initialize() {
try (ResultSet resultSet = singleUse().executeQuery(DatabaseMetadata.STATEMENT)) {
return DatabaseMetadata.fromResultSet(resultSet);
}
// This should not really happen, but it is the safest fallback value.
return Dialect.GOOGLE_STANDARD_SQL;
}

@Override
Expand All @@ -556,15 +550,24 @@ protected boolean shouldCacheError(Exception e) {
@Override
public Dialect getDialect() {
try {
return dialectSupplier.get();
return databaseMetadataSupplier.get().getDialect();
} catch (Exception exception) {
throw SpannerExceptionFactory.asSpannerException(exception);
}
}

Future<Dialect> getDialectAsync() {
try {
return MAINTAINER_SERVICE.submit(dialectSupplier::get);
return MAINTAINER_SERVICE.submit(() -> databaseMetadataSupplier.get().getDialect());
} catch (Exception exception) {
throw SpannerExceptionFactory.asSpannerException(exception);
}
}

@Override
public IsolationLevel getDatabaseDefaultIsolationLevel() {
try {
return databaseMetadataSupplier.get().getDefaultIsolationLevel();
} catch (Exception exception) {
throw SpannerExceptionFactory.asSpannerException(exception);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3212,7 +3212,7 @@ public void testGetDialect_RecoversAfterTransientCreateSessionFailure() throws E
public void testGetDialectAndStatementFactory_RecoverAfterTransientQueryFailure() {
mockSpanner.putStatementResult(
StatementResult.exception(
MultiplexedSessionDatabaseClient.DETERMINE_DIALECT_STATEMENT,
DatabaseMetadata.STATEMENT,
Status.DEADLINE_EXCEEDED
.withDescription("Transient dialect query timeout")
.asRuntimeException()));
Expand Down Expand Up @@ -3245,7 +3245,7 @@ public void testGetDialectAndStatementFactory_RecoverAfterTransientQueryFailure(
public void testGetDialect_CachesNonRetryableQueryFailure() {
mockSpanner.putStatementResult(
StatementResult.exception(
MultiplexedSessionDatabaseClient.DETERMINE_DIALECT_STATEMENT,
DatabaseMetadata.STATEMENT,
Status.PERMISSION_DENIED
.withDescription("Permission denied on information_schema")
.asRuntimeException()));
Expand All @@ -3263,6 +3263,56 @@ public void testGetDialect_CachesNonRetryableQueryFailure() {
}
}

@Test
public void testGetDatabaseDefaultIsolationLevelDefault() {
DatabaseClient client =
spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE));
assertEquals(
IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED, client.getDatabaseDefaultIsolationLevel());
}

@Test
public void testGetDatabaseDefaultIsolationLevelRepeatableRead() {
mockSpanner.putStatementResult(
StatementResult.detectDialectResult(Dialect.POSTGRESQL, IsolationLevel.REPEATABLE_READ));
try {
DatabaseClient client =
spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE));
assertEquals(Dialect.POSTGRESQL, client.getDialect());
assertEquals(IsolationLevel.REPEATABLE_READ, client.getDatabaseDefaultIsolationLevel());
} finally {
mockSpanner.putStatementResult(
StatementResult.detectDialectResult(Dialect.GOOGLE_STANDARD_SQL));
}
}

@Test
public void testGetDialectAndDefaultIsolationLevelCachedWithSingleQuery() {
mockSpanner.putStatementResult(
StatementResult.detectDialectResult(Dialect.POSTGRESQL, IsolationLevel.REPEATABLE_READ));
mockSpanner.setIncludeDetermineDialectStatementInRequests(true);
try {
DatabaseClient client =
spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE));
assertEquals(IsolationLevel.REPEATABLE_READ, client.getDatabaseDefaultIsolationLevel());
assertEquals(Dialect.POSTGRESQL, client.getDialect());
assertEquals(IsolationLevel.REPEATABLE_READ, client.getDatabaseDefaultIsolationLevel());
assertEquals(Dialect.POSTGRESQL, client.getDialect());

assertEquals(1L, countDatabaseMetadataQueries());
} finally {
mockSpanner.setIncludeDetermineDialectStatementInRequests(false);
mockSpanner.putStatementResult(
StatementResult.detectDialectResult(Dialect.GOOGLE_STANDARD_SQL));
}
}

private long countDatabaseMetadataQueries() {
return mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream()
.filter(request -> request.getSql().equals(DatabaseMetadata.STATEMENT.getSql()))
.count();
}

@Test
public void testUntypedNullParameters() {
Statement statement =
Expand Down
Loading
Loading