diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java index 2f843aeb0c4..9dda394bc38 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java @@ -172,6 +172,7 @@ public String[] getValidValues() { private static final RpcPriority DEFAULT_RPC_PRIORITY = null; private static final boolean DEFAULT_RETURN_COMMIT_STATS = false; private static final boolean DEFAULT_LENIENT = false; + private static final boolean DEFAULT_ROUTE_TO_LEADER = true; private static final boolean DEFAULT_DELAY_TRANSACTION_START_UNTIL_FIRST_WRITE = false; private static final boolean DEFAULT_TRACK_SESSION_LEAKS = true; private static final boolean DEFAULT_TRACK_CONNECTION_LEAKS = true; @@ -186,6 +187,8 @@ public String[] getValidValues() { public static final String AUTOCOMMIT_PROPERTY_NAME = "autocommit"; /** Name of the 'readonly' connection property. */ public static final String READONLY_PROPERTY_NAME = "readonly"; + /** Name of the 'routeToLeader' connection property. */ + public static final String ROUTE_TO_LEADER_PROPERTY_NAME = "routeToLeader"; /** Name of the 'retry aborts internally' connection property. */ public static final String RETRY_ABORTS_INTERNALLY_PROPERTY_NAME = "retryAbortsInternally"; /** Name of the 'credentials' connection property. */ @@ -241,6 +244,10 @@ public String[] getValidValues() { READONLY_PROPERTY_NAME, "Should the connection start in read-only mode (true/false)", DEFAULT_READONLY), + ConnectionProperty.createBooleanProperty( + ROUTE_TO_LEADER_PROPERTY_NAME, + "Should read/write transactions and partitioned DML be routed to leader region (true/false)", + DEFAULT_ROUTE_TO_LEADER), ConnectionProperty.createBooleanProperty( RETRY_ABORTS_INTERNALLY_PROPERTY_NAME, "Should the connection automatically retry Aborted errors (true/false)", @@ -462,6 +469,8 @@ private boolean isValidUri(String uri) { * created on the emulator if any of them do not yet exist. Any existing instance or * database on the emulator will remain untouched. No other configuration is needed in * order to connect to the emulator than setting this property. + *
  • routeToLeader (boolean): Sets the routeToLeader flag to route requests to leader (true) + * or any region (false) in read/write transactions and Partitioned DML. Default is true. * * * @param uri The URI of the Spanner database to connect to. @@ -586,6 +595,7 @@ public static Builder newBuilder() { private final boolean autocommit; private final boolean readOnly; + private final boolean routeToLeader; private final boolean retryAbortsInternally; private final List statementExecutionInterceptors; private final SpannerOptionsConfigurator configurator; @@ -678,6 +688,7 @@ private ConnectionOptions(Builder builder) { this.autocommit = parseAutocommit(this.uri); this.readOnly = parseReadOnly(this.uri); + this.routeToLeader = parseRouteToLeader(this.uri); this.retryAbortsInternally = parseRetryAbortsInternally(this.uri); this.statementExecutionInterceptors = Collections.unmodifiableList(builder.statementExecutionInterceptors); @@ -762,6 +773,11 @@ static boolean parseReadOnly(String uri) { return value != null ? Boolean.parseBoolean(value) : DEFAULT_READONLY; } + static boolean parseRouteToLeader(String uri) { + String value = parseUriProperty(uri, ROUTE_TO_LEADER_PROPERTY_NAME); + return value != null ? Boolean.parseBoolean(value) : DEFAULT_ROUTE_TO_LEADER; + } + @VisibleForTesting static boolean parseRetryAbortsInternally(String uri) { String value = parseUriProperty(uri, RETRY_ABORTS_INTERNALLY_PROPERTY_NAME); @@ -1089,6 +1105,14 @@ public boolean isReadOnly() { return readOnly; } + /** + * Whether read/write transactions and partitioned DML are preferred to be routed to the leader + * region. + */ + public boolean isRouteToLeader() { + return routeToLeader; + } + /** * The initial retryAbortsInternally value for connections created by this {@link * ConnectionOptions} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java index 6a57779020e..df7190f11a1 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java @@ -154,6 +154,7 @@ static class SpannerPoolKey { private final boolean usePlainText; private final String userAgent; private final String databaseRole; + private final boolean routeToLeader; @VisibleForTesting static SpannerPoolKey of(ConnectionOptions options) { @@ -179,6 +180,7 @@ private SpannerPoolKey(ConnectionOptions options) throws IOException { this.numChannels = options.getNumChannels(); this.usePlainText = options.isUsePlainText(); this.userAgent = options.getUserAgent(); + this.routeToLeader = options.isRouteToLeader(); } @Override @@ -194,7 +196,8 @@ public boolean equals(Object o) { && Objects.equals(this.numChannels, other.numChannels) && Objects.equals(this.databaseRole, other.databaseRole) && Objects.equals(this.usePlainText, other.usePlainText) - && Objects.equals(this.userAgent, other.userAgent); + && Objects.equals(this.userAgent, other.userAgent) + && Objects.equals(this.routeToLeader, other.routeToLeader); } @Override @@ -207,7 +210,8 @@ public int hashCode() { this.numChannels, this.usePlainText, this.databaseRole, - this.userAgent); + this.userAgent, + this.routeToLeader); } } @@ -342,6 +346,9 @@ Spanner createSpanner(SpannerPoolKey key, ConnectionOptions options) { if (options.getChannelProvider() != null) { builder.setChannelProvider(options.getChannelProvider()); } + if (!options.isRouteToLeader()) { + builder.disableLeaderAwareRouting(); + } if (key.usePlainText) { // Credentials may not be sent over a plain text channel. builder.setCredentials(NoCredentials.getInstance()); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionOptionsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionOptionsTest.java index ef1ab365577..bfd356dda55 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionOptionsTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionOptionsTest.java @@ -49,6 +49,9 @@ public class ConnectionOptionsTest { private static final String FILE_TEST_PATH = ConnectionOptionsTest.class.getResource("test-key.json").getFile(); private static final String DEFAULT_HOST = "https://spanner.googleapis.com"; + private static final String TEST_PROJECT = "test-project-123"; + private static final String TEST_INSTANCE = "test-instance-123"; + private static final String TEST_DATABASE = "test-database-123"; @Test public void testBuildWithURIWithDots() { @@ -149,6 +152,27 @@ public void testBuildWithAutoConfigEmulator() { assertTrue(options.isUsePlainText()); } + @Test + public void testBuildWithRouteToLeader() { + final String BASE_URI = + "cloudspanner:/projects/test-project-123/instances/test-instance-123/databases/test-database-123"; + ConnectionOptions.Builder builder = ConnectionOptions.newBuilder(); + builder.setUri(BASE_URI + "?routeToLeader=false"); + builder.setCredentialsUrl(FILE_TEST_PATH); + ConnectionOptions options = builder.build(); + assertEquals(options.getHost(), DEFAULT_HOST); + assertEquals(options.getProjectId(), TEST_PROJECT); + assertEquals(options.getInstanceId(), TEST_INSTANCE); + assertEquals(options.getDatabaseName(), TEST_DATABASE); + assertFalse(options.isRouteToLeader()); + + // Test for default behavior for routeToLeader property. + builder = ConnectionOptions.newBuilder().setUri(BASE_URI); + builder.setCredentialsUrl(FILE_TEST_PATH); + options = builder.build(); + assertTrue(options.isRouteToLeader()); + } + @Test public void testBuildWithAutoConfigEmulatorAndHost() { ConnectionOptions.Builder builder = ConnectionOptions.newBuilder(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/SpannerPoolTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/SpannerPoolTest.java index 59c9065e41e..d11f0f389f8 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/SpannerPoolTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/SpannerPoolTest.java @@ -65,6 +65,8 @@ public class SpannerPoolTest { private ConnectionOptions options5 = mock(ConnectionOptions.class); private ConnectionOptions options6 = mock(ConnectionOptions.class); + private ConnectionOptions options7 = mock(ConnectionOptions.class); + private ConnectionOptions options8 = mock(ConnectionOptions.class); private SpannerPool createSubjectAndMocks() { return createSubjectAndMocks(0L, Ticker.systemTicker()); @@ -93,6 +95,10 @@ Spanner createSpanner(SpannerPoolKey key, ConnectionOptions options) { // ConnectionOptions with no specific credentials. when(options5.getProjectId()).thenReturn("test-project-3"); when(options6.getProjectId()).thenReturn("test-project-3"); + when(options7.getProjectId()).thenReturn("test-project-3"); + when(options7.isRouteToLeader()).thenReturn(true); + when(options8.getProjectId()).thenReturn("test-project-3"); + when(options8.isRouteToLeader()).thenReturn(false); return pool; } @@ -111,40 +117,43 @@ public void testGetSpanner() { // assert equal spanner1 = pool.getSpanner(options1, connection1); spanner2 = pool.getSpanner(options1, connection2); - assertThat(spanner1).isEqualTo(spanner2); + assertEquals(spanner1, spanner2); spanner1 = pool.getSpanner(options2, connection1); spanner2 = pool.getSpanner(options2, connection2); - assertThat(spanner1).isEqualTo(spanner2); + assertEquals(spanner1, spanner2); spanner1 = pool.getSpanner(options3, connection1); spanner2 = pool.getSpanner(options3, connection2); - assertThat(spanner1).isEqualTo(spanner2); + assertEquals(spanner1, spanner2); spanner1 = pool.getSpanner(options4, connection1); spanner2 = pool.getSpanner(options4, connection2); - assertThat(spanner1).isEqualTo(spanner2); + assertEquals(spanner1, spanner2); // Options 5 and 6 both use default credentials. spanner1 = pool.getSpanner(options5, connection1); spanner2 = pool.getSpanner(options6, connection2); - assertThat(spanner1).isEqualTo(spanner2); + assertEquals(spanner1, spanner2); // assert not equal spanner1 = pool.getSpanner(options1, connection1); spanner2 = pool.getSpanner(options2, connection2); - assertThat(spanner1).isNotEqualTo(spanner2); + assertNotEquals(spanner1, spanner2); spanner1 = pool.getSpanner(options1, connection1); spanner2 = pool.getSpanner(options3, connection2); - assertThat(spanner1).isNotEqualTo(spanner2); + assertNotEquals(spanner1, spanner2); spanner1 = pool.getSpanner(options1, connection1); spanner2 = pool.getSpanner(options4, connection2); - assertThat(spanner1).isNotEqualTo(spanner2); + assertNotEquals(spanner1, spanner2); spanner1 = pool.getSpanner(options2, connection1); spanner2 = pool.getSpanner(options3, connection2); - assertThat(spanner1).isNotEqualTo(spanner2); + assertNotEquals(spanner1, spanner2); spanner1 = pool.getSpanner(options2, connection1); spanner2 = pool.getSpanner(options4, connection2); - assertThat(spanner1).isNotEqualTo(spanner2); + assertNotEquals(spanner1, spanner2); spanner1 = pool.getSpanner(options3, connection1); spanner2 = pool.getSpanner(options4, connection2); - assertThat(spanner1).isNotEqualTo(spanner2); + assertNotEquals(spanner1, spanner2); + spanner1 = pool.getSpanner(options7, connection1); + spanner2 = pool.getSpanner(options8, connection2); + assertNotEquals(spanner1, spanner2); } @Test @@ -460,14 +469,30 @@ public void testSpannerPoolKeyEquality() { .setUri("cloudspanner:/projects/p/instances/i/databases/d") .setCredentials(NoCredentials.getInstance()) .build(); + // Not passing in routeToLeader in Connection URI is equivalent to passing it as true, + // as routeToLeader is true by default. + ConnectionOptions options4 = + ConnectionOptions.newBuilder() + .setUri("cloudspanner:/projects/p/instances/i/databases/d?routeToLeader=true") + .setCredentials(NoCredentials.getInstance()) + .build(); + ConnectionOptions options5 = + ConnectionOptions.newBuilder() + .setUri("cloudspanner:/projects/p/instances/i/databases/d?routeToLeader=false") + .setCredentials(NoCredentials.getInstance()) + .build(); SpannerPoolKey key1 = SpannerPoolKey.of(options1); SpannerPoolKey key2 = SpannerPoolKey.of(options2); SpannerPoolKey key3 = SpannerPoolKey.of(options3); + SpannerPoolKey key4 = SpannerPoolKey.of(options4); + SpannerPoolKey key5 = SpannerPoolKey.of(options5); assertNotEquals(key1, key2); assertEquals(key2, key3); assertNotEquals(key1, key3); assertNotEquals(key1, new Object()); + assertEquals(key3, key4); + assertNotEquals(key4, key5); } }