From 885b79926a46fc89fc56df3aca3245787d3f5fb1 Mon Sep 17 00:00:00 2001 From: Eno Compton Date: Sun, 12 Jan 2025 17:38:49 -0700 Subject: [PATCH] feat: add support for lazy refresh By default the connector will continue to use a refresh ahead strategy, where client certificates are refreshed by a background thread. The lazy strategy by comparison is useful for when the Connector runs in serverless environments and background threads may not run reliably, e.g., when the CPU is throttled. This commit adds the lazy refresh strategy and updates the configuration documentation to demonstrate how to use the feature. Fixes #565 --- .../cloud/alloydb/ConnectionConfig.java | 32 +++-- .../google/cloud/alloydb/ConnectorConfig.java | 25 +++- .../DefaultConnectionInfoCacheFactory.java | 13 +- .../alloydb/InternalConnectorRegistry.java | 2 +- .../alloydb/LazyConnectionInfoCache.java | 133 ++++++++++++++++++ ...a => RefreshAheadConnectionInfoCache.java} | 8 +- .../cloud/alloydb/RefreshCalculator.java | 2 +- .../google/cloud/alloydb/RefreshStrategy.java | 23 +++ ...AlloyDbJdbcConnectorDataSourceFactory.java | 1 + ...DbJdbcNamedConnectorDataSourceFactory.java | 4 +- .../cloud/alloydb/ConnectionConfigTest.java | 4 + .../cloud/alloydb/ConnectorConfigTest.java | 28 +++- .../google/cloud/alloydb/ConnectorTest.java | 2 +- .../google/cloud/alloydb/ITConnectorTest.java | 8 +- .../alloydb/LazyConnectionInfoCacheTest.java | 122 ++++++++++++++++ ... RefreshAheadConnectionInfoCacheTest.java} | 14 +- docs/configuration.md | 15 +- 17 files changed, 389 insertions(+), 47 deletions(-) create mode 100644 alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/LazyConnectionInfoCache.java rename alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/{DefaultConnectionInfoCache.java => RefreshAheadConnectionInfoCache.java} (87%) create mode 100644 alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshStrategy.java create mode 100644 alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/LazyConnectionInfoCacheTest.java rename alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/{ConnectionInfoCacheTest.java => RefreshAheadConnectionInfoCacheTest.java} (95%) diff --git a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/ConnectionConfig.java b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/ConnectionConfig.java index 36779685..b7567941 100644 --- a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/ConnectionConfig.java +++ b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/ConnectionConfig.java @@ -35,6 +35,7 @@ class ConnectionConfig { public static final String ALLOYDB_QUOTA_PROJECT = "alloydbQuotaProject"; public static final String ENABLE_IAM_AUTH_PROPERTY = "alloydbEnableIAMAuth"; public static final String ALLOYDB_IP_TYPE = "alloydbIpType"; + public static final String ALLOYDB_REFRESH_STRATEGY = "alloydbRefreshStrategy"; public static final AuthType DEFAULT_AUTH_TYPE = AuthType.PASSWORD; public static final IpType DEFAULT_IP_TYPE = IpType.PRIVATE; private final InstanceName instanceName; @@ -48,29 +49,31 @@ static ConnectionConfig fromConnectionProperties(Properties props) { validateProperties(props); final String instanceNameStr = props.getProperty(ALLOYDB_INSTANCE_NAME, ""); final InstanceName instanceName = InstanceName.parse(instanceNameStr); - final String namedConnector = props.getProperty(ConnectionConfig.ALLOYDB_NAMED_CONNECTOR); - final String adminServiceEndpoint = - props.getProperty(ConnectionConfig.ALLOYDB_ADMIN_SERVICE_ENDPOINT); - final String targetPrincipal = props.getProperty(ConnectionConfig.ALLOYDB_TARGET_PRINCIPAL); - final String delegatesStr = props.getProperty(ConnectionConfig.ALLOYDB_DELEGATES); + final String namedConnector = props.getProperty(ALLOYDB_NAMED_CONNECTOR); + final String adminServiceEndpoint = props.getProperty(ALLOYDB_ADMIN_SERVICE_ENDPOINT); + final String targetPrincipal = props.getProperty(ALLOYDB_TARGET_PRINCIPAL); + final String delegatesStr = props.getProperty(ALLOYDB_DELEGATES); final List delegates; if (delegatesStr != null && !delegatesStr.isEmpty()) { delegates = Arrays.asList(delegatesStr.split(",")); } else { delegates = Collections.emptyList(); } - final String googleCredentialsPath = - props.getProperty(ConnectionConfig.ALLOYDB_GOOGLE_CREDENTIALS_PATH); + final String googleCredentialsPath = props.getProperty(ALLOYDB_GOOGLE_CREDENTIALS_PATH); final AuthType authType = - Boolean.parseBoolean(props.getProperty(ConnectionConfig.ENABLE_IAM_AUTH_PROPERTY)) + Boolean.parseBoolean(props.getProperty(ENABLE_IAM_AUTH_PROPERTY)) ? AuthType.IAM : AuthType.PASSWORD; - final String quotaProject = props.getProperty(ConnectionConfig.ALLOYDB_QUOTA_PROJECT); + final String quotaProject = props.getProperty(ALLOYDB_QUOTA_PROJECT); IpType ipType = IpType.PRIVATE; - if (props.getProperty(ConnectionConfig.ALLOYDB_IP_TYPE) != null) { - ipType = - IpType.valueOf( - props.getProperty(ConnectionConfig.ALLOYDB_IP_TYPE).toUpperCase(Locale.getDefault())); + if (props.getProperty(ALLOYDB_IP_TYPE) != null) { + ipType = IpType.valueOf(props.getProperty(ALLOYDB_IP_TYPE).toUpperCase(Locale.getDefault())); + } + RefreshStrategy refreshStrategy = RefreshStrategy.REFRESH_AHEAD; + if (props.getProperty(ALLOYDB_REFRESH_STRATEGY) != null) { + refreshStrategy = + RefreshStrategy.valueOf( + props.getProperty(ALLOYDB_REFRESH_STRATEGY).toUpperCase(Locale.getDefault())); } return new ConnectionConfig( @@ -84,6 +87,7 @@ static ConnectionConfig fromConnectionProperties(Properties props) { .withAdminServiceEndpoint(adminServiceEndpoint) .withGoogleCredentialsPath(googleCredentialsPath) .withQuotaProject(quotaProject) + .withRefreshStrategy(refreshStrategy) .build()); } @@ -110,7 +114,7 @@ public int hashCode() { private static void validateProperties(Properties props) { final String instanceNameStr = props.getProperty(ALLOYDB_INSTANCE_NAME, ""); Preconditions.checkArgument( - InstanceName.isParsableFrom(instanceNameStr) == true, + InstanceName.isParsableFrom(instanceNameStr), String.format( "'%s' must have format: projects//locations//clusters//instances/", ALLOYDB_INSTANCE_NAME)); diff --git a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/ConnectorConfig.java b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/ConnectorConfig.java index 02ffe1d9..d3fba3d1 100644 --- a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/ConnectorConfig.java +++ b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/ConnectorConfig.java @@ -35,6 +35,7 @@ public class ConnectorConfig { private final GoogleCredentials googleCredentials; private final String googleCredentialsPath; private final String quotaProject; + private final RefreshStrategy refreshStrategy; private ConnectorConfig( String targetPrincipal, @@ -43,7 +44,8 @@ private ConnectorConfig( Supplier googleCredentialsSupplier, GoogleCredentials googleCredentials, String googleCredentialsPath, - String quotaProject) { + String quotaProject, + RefreshStrategy refreshStrategy) { this.targetPrincipal = targetPrincipal; this.delegates = delegates; this.adminServiceEndpoint = adminServiceEndpoint; @@ -51,6 +53,7 @@ private ConnectorConfig( this.googleCredentials = googleCredentials; this.googleCredentialsPath = googleCredentialsPath; this.quotaProject = quotaProject; + this.refreshStrategy = refreshStrategy; } @Override @@ -68,7 +71,8 @@ public boolean equals(Object o) { && Objects.equal(googleCredentialsSupplier, that.googleCredentialsSupplier) && Objects.equal(googleCredentials, that.googleCredentials) && Objects.equal(googleCredentialsPath, that.googleCredentialsPath) - && Objects.equal(quotaProject, that.quotaProject); + && Objects.equal(quotaProject, that.quotaProject) + && Objects.equal(refreshStrategy, that.refreshStrategy); } @Override @@ -80,7 +84,8 @@ public int hashCode() { googleCredentialsSupplier, googleCredentials, googleCredentialsPath, - quotaProject); + quotaProject, + refreshStrategy); } public String getTargetPrincipal() { @@ -110,6 +115,11 @@ public String getGoogleCredentialsPath() { public String getQuotaProject() { return quotaProject; } + + public RefreshStrategy getRefreshStrategy() { + return refreshStrategy; + } + /** The builder for the ConnectionConfig. */ public static class Builder { @@ -120,6 +130,7 @@ public static class Builder { private GoogleCredentials googleCredentials; private String googleCredentialsPath; private String quotaProject; + private RefreshStrategy refreshStrategy; public Builder withTargetPrincipal(String targetPrincipal) { this.targetPrincipal = targetPrincipal; @@ -157,6 +168,11 @@ public Builder withQuotaProject(String quotaProject) { return this; } + public Builder withRefreshStrategy(RefreshStrategy refreshStrategy) { + this.refreshStrategy = refreshStrategy; + return this; + } + /** Builds a new instance of {@code ConnectionConfig}. */ public ConnectorConfig build() { // validate only one GoogleCredentials configuration field set @@ -183,7 +199,8 @@ public ConnectorConfig build() { googleCredentialsSupplier, googleCredentials, googleCredentialsPath, - quotaProject); + quotaProject, + refreshStrategy); } } } diff --git a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/DefaultConnectionInfoCacheFactory.java b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/DefaultConnectionInfoCacheFactory.java index d003c6da..c2ba40e1 100644 --- a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/DefaultConnectionInfoCacheFactory.java +++ b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/DefaultConnectionInfoCacheFactory.java @@ -25,14 +25,23 @@ */ class DefaultConnectionInfoCacheFactory implements ConnectionInfoCacheFactory { + private final RefreshStrategy refreshStrategy; + + public DefaultConnectionInfoCacheFactory(RefreshStrategy refreshStrategy) { + this.refreshStrategy = refreshStrategy; + } + @Override - public DefaultConnectionInfoCache create( + public ConnectionInfoCache create( ListeningScheduledExecutorService executor, ConnectionInfoRepository connectionInfoRepo, InstanceName instanceName, KeyPair clientConnectorKeyPair, long minRefreshDelayMs) { - return new DefaultConnectionInfoCache( + if (refreshStrategy == RefreshStrategy.LAZY) { + return new LazyConnectionInfoCache(connectionInfoRepo, instanceName, clientConnectorKeyPair); + } + return new RefreshAheadConnectionInfoCache( executor, connectionInfoRepo, instanceName, clientConnectorKeyPair, minRefreshDelayMs); } } diff --git a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/InternalConnectorRegistry.java b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/InternalConnectorRegistry.java index 72df3c4b..9b566f6b 100644 --- a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/InternalConnectorRegistry.java +++ b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/InternalConnectorRegistry.java @@ -199,7 +199,7 @@ private Connector createConnector(ConnectorConfig config) { executor, connectionInfoRepository, RsaKeyPairGenerator.generateKeyPair(), - new DefaultConnectionInfoCacheFactory(), + new DefaultConnectionInfoCacheFactory(config.getRefreshStrategy()), new ConcurrentHashMap<>(), accessTokenSupplier, getUserAgents()); diff --git a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/LazyConnectionInfoCache.java b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/LazyConnectionInfoCache.java new file mode 100644 index 00000000..175cd386 --- /dev/null +++ b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/LazyConnectionInfoCache.java @@ -0,0 +1,133 @@ +/* + * Copyright 2025 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.alloydb; + +import com.google.cloud.alloydb.v1alpha.InstanceName; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.errorprone.annotations.concurrent.GuardedBy; +import java.security.KeyPair; +import java.time.Instant; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LazyConnectionInfoCache implements ConnectionInfoCache { + + // Client timeout seconds is the number of seconds to wait for the future to resolve holding the + // connection info data. + public static final int CLIENT_TIMEOUT_SECONDS = 30; + private final Logger logger = LoggerFactory.getLogger(LazyConnectionInfoCache.class); + + private final ConnectionInfoRepository connectionInfoRepo; + private final InstanceName instanceURI; + private final KeyPair clientConnectorKeyPair; + + private final Object connectionInfoGuard = new Object(); + + @GuardedBy("connectionInfoGuard") + private ConnectionInfo connectionInfo; + + @GuardedBy("connectionInfoGuard") + private boolean closed; + + public LazyConnectionInfoCache( + ConnectionInfoRepository connectionInfoRepo, + InstanceName instanceURI, + KeyPair clientConnectorKeyPair) { + this.connectionInfoRepo = connectionInfoRepo; + this.instanceURI = instanceURI; + this.clientConnectorKeyPair = clientConnectorKeyPair; + } + + @Override + public ConnectionInfo getConnectionInfo() { + synchronized (connectionInfoGuard) { + if (closed) { + throw new IllegalStateException( + String.format("[%s] Lazy Refresh: Named connection closed.", instanceURI)); + } + + if (connectionInfo == null || needsRefresh(connectionInfo.getExpiration())) { + logger.debug( + String.format( + "[%s] Lazy Refresh Operation: Client certificate needs refresh. Starting next " + + "refresh operation...", + instanceURI)); + + try { + ListenableFuture infoFuture = + connectionInfoRepo.getConnectionInfo(instanceURI, clientConnectorKeyPair); + this.connectionInfo = infoFuture.get(CLIENT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TerminalException e) { + logger.debug( + String.format( + "[%s] Lazy Refresh Operation: Failed with a terminal error.", instanceURI), + e); + throw e; + } catch (Exception e) { + throw new RuntimeException( + String.format("[%s] Refresh Operation: Failed!", instanceURI), e); + } + } + + logger.debug( + String.format( + "[%s] Lazy Refresh Operation: Completed refresh with new certificate " + + "expiration at %s.", + instanceURI, this.connectionInfo.getExpiration().toString())); + return connectionInfo; + } + } + + private boolean needsRefresh(Instant expiration) { + return Instant.now().isAfter(expiration.minus(RefreshCalculator.DEFAULT_REFRESH_BUFFER)); + } + + /** Force a new refresh of the instance data if the client certificate has expired. */ + @Override + public void forceRefresh() { + // invalidate connectionInfo so that the next call to getConectionInfo() will + // fetch new data. + synchronized (connectionInfoGuard) { + if (closed) { + throw new IllegalStateException( + String.format("[%s] Lazy Refresh: Named connection closed.", instanceURI)); + } + this.connectionInfo = null; + logger.debug(String.format("[%s] Lazy Refresh Operation: Forced refresh.", instanceURI)); + } + } + + /** Force a new refresh of the instance data if the client certificate has expired. */ + @Override + public void refreshIfExpired() { + synchronized (connectionInfoGuard) { + if (closed) { + throw new IllegalStateException( + String.format("[%s] Lazy Refresh: Named connection closed.", instanceURI)); + } + } + } + + @Override + public void close() { + synchronized (connectionInfoGuard) { + closed = true; + logger.debug(String.format("[%s] Lazy Refresh Operation: Connector closed.", instanceURI)); + } + } +} diff --git a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/DefaultConnectionInfoCache.java b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshAheadConnectionInfoCache.java similarity index 87% rename from alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/DefaultConnectionInfoCache.java rename to alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshAheadConnectionInfoCache.java index dcbf403f..5c499564 100644 --- a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/DefaultConnectionInfoCache.java +++ b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshAheadConnectionInfoCache.java @@ -21,16 +21,16 @@ import java.security.KeyPair; /** - * DefaultConnectionInfoCache is the cache used by default to hold connection info. In testing, this - * class may be replaced with alternative implementations of ConnectionInfoCache. + * RefreshAheadConnectionInfoCache is the cache used by default to hold connection info. In testing, + * this class may be replaced with alternative implementations of ConnectionInfoCache. */ -class DefaultConnectionInfoCache implements ConnectionInfoCache { +class RefreshAheadConnectionInfoCache implements ConnectionInfoCache { private final Refresher refresher; private static final long DEFAULT_TIMEOUT_MS = 30000; - DefaultConnectionInfoCache( + RefreshAheadConnectionInfoCache( ListeningScheduledExecutorService executor, ConnectionInfoRepository connectionInfoRepo, InstanceName instanceName, diff --git a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshCalculator.java b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshCalculator.java index f543fba2..832b0836 100644 --- a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshCalculator.java +++ b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshCalculator.java @@ -27,7 +27,7 @@ class RefreshCalculator { // defaultRefreshBuffer is the minimum amount of time for which a // certificate must be valid to ensure the next refresh attempt has adequate // time to complete. - private static final Duration DEFAULT_REFRESH_BUFFER = Duration.ofMinutes(4); + static final Duration DEFAULT_REFRESH_BUFFER = Duration.ofMinutes(4); long calculateSecondsUntilNextRefresh(Instant now, Instant expiration) { Duration timeUntilExp = Duration.between(now, expiration); diff --git a/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshStrategy.java b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshStrategy.java new file mode 100644 index 00000000..2c4ecec8 --- /dev/null +++ b/alloydb-jdbc-connector/src/main/java/com/google/cloud/alloydb/RefreshStrategy.java @@ -0,0 +1,23 @@ +/* + * Copyright 2025 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.alloydb; + +public enum RefreshStrategy { + /* Refresh ahead will use a background thread to refresh client certificates before they expire */ + REFRESH_AHEAD, + LAZY +} diff --git a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/AlloyDbJdbcConnectorDataSourceFactory.java b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/AlloyDbJdbcConnectorDataSourceFactory.java index 074ef7ac..2b4843ed 100644 --- a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/AlloyDbJdbcConnectorDataSourceFactory.java +++ b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/AlloyDbJdbcConnectorDataSourceFactory.java @@ -16,6 +16,7 @@ package com.google.cloud.alloydb; // [START alloydb_hikaricp_connect_connector] + import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; diff --git a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/AlloyDbJdbcNamedConnectorDataSourceFactory.java b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/AlloyDbJdbcNamedConnectorDataSourceFactory.java index 6ff0d2b5..e80e8344 100644 --- a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/AlloyDbJdbcNamedConnectorDataSourceFactory.java +++ b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/AlloyDbJdbcNamedConnectorDataSourceFactory.java @@ -16,6 +16,7 @@ package com.google.cloud.alloydb; // [START alloydb_hikaricp_connect_connector_named] + import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @@ -30,7 +31,8 @@ public class AlloyDbJdbcNamedConnectorDataSourceFactory { static HikariDataSource createDataSource() { // Register a named Connector - ConnectorConfig namedConnectorConfig = new ConnectorConfig.Builder().build(); + ConnectorConfig namedConnectorConfig = + new ConnectorConfig.Builder().withRefreshStrategy(RefreshStrategy.LAZY).build(); ConnectorRegistry.register("my-connector", namedConnectorConfig); HikariConfig config = new HikariConfig(); diff --git a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectionConfigTest.java b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectionConfigTest.java index a27f9ed7..29dfa48c 100644 --- a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectionConfigTest.java +++ b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectionConfigTest.java @@ -42,6 +42,7 @@ public void testConfigFromProps() { final String iamAuthN = "true"; final String wantQuotaProject = "myNewProject"; final String ipType = "PUBLIC"; + final String refreshStrategy = "REFRESH_AHEAD"; Properties props = new Properties(); props.setProperty(ConnectionConfig.ALLOYDB_INSTANCE_NAME, INSTANCE_NAME); @@ -53,6 +54,7 @@ public void testConfigFromProps() { props.setProperty(ConnectionConfig.ENABLE_IAM_AUTH_PROPERTY, iamAuthN); props.setProperty(ConnectionConfig.ALLOYDB_QUOTA_PROJECT, wantQuotaProject); props.setProperty(ConnectionConfig.ALLOYDB_IP_TYPE, ipType); + props.setProperty(ConnectionConfig.ALLOYDB_REFRESH_STRATEGY, refreshStrategy); ConnectionConfig config = ConnectionConfig.fromConnectionProperties(props); @@ -66,6 +68,8 @@ public void testConfigFromProps() { assertThat(config.getConnectorConfig().getQuotaProject()).isEqualTo(wantQuotaProject); assertThat(config.getAuthType()).isEqualTo(AuthType.IAM); assertThat(config.getIpType()).isEqualTo(IpType.PUBLIC); + assertThat(config.getConnectorConfig().getRefreshStrategy()) + .isEqualTo(RefreshStrategy.REFRESH_AHEAD); } @Test diff --git a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectorConfigTest.java b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectorConfigTest.java index de699354..a86dccb1 100644 --- a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectorConfigTest.java +++ b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectorConfigTest.java @@ -40,12 +40,14 @@ public void testConfigFromBuilder() { .withDelegates(wantDelegates) .withAdminServiceEndpoint(wantAdminServiceEndpoint) .withQuotaProject(wantQuotaProject) + .withRefreshStrategy(RefreshStrategy.REFRESH_AHEAD) .build(); assertThat(cc.getTargetPrincipal()).isEqualTo(wantTargetPrincipal); assertThat(cc.getDelegates()).isEqualTo(wantDelegates); assertThat(cc.getAdminServiceEndpoint()).isEqualTo(wantAdminServiceEndpoint); assertThat(cc.getQuotaProject()).isEqualTo(wantQuotaProject); + assertThat(cc.getRefreshStrategy()).isEqualTo(RefreshStrategy.REFRESH_AHEAD); } @Test @@ -140,6 +142,28 @@ public void testEqual_withQuotaProjectEqual() { assertThat(k1.hashCode()).isEqualTo(k2.hashCode()); } + @Test + public void testEqual_withRefreshStrategyEqual() { + ConnectorConfig k1 = + new ConnectorConfig.Builder().withRefreshStrategy(RefreshStrategy.LAZY).build(); + ConnectorConfig k2 = + new ConnectorConfig.Builder().withRefreshStrategy(RefreshStrategy.LAZY).build(); + + assertThat(k1).isEqualTo(k2); + assertThat(k1.hashCode()).isEqualTo(k2.hashCode()); + } + + @Test + public void testEqual_withRefreshStrategyNotEqual() { + ConnectorConfig k1 = + new ConnectorConfig.Builder().withRefreshStrategy(RefreshStrategy.LAZY).build(); + ConnectorConfig k2 = + new ConnectorConfig.Builder().withRefreshStrategy(RefreshStrategy.REFRESH_AHEAD).build(); + + assertThat(k1).isNotEqualTo(k2); + assertThat(k1.hashCode()).isNotEqualTo(k2.hashCode()); + } + @Test public void testBuild_withGoogleCredentialsPath() { final String wantGoogleCredentialsPath = "/path/to/credentials"; @@ -219,6 +243,7 @@ public void testHashCode() { .withAdminServiceEndpoint(wantAdminServiceEndpoint) .withGoogleCredentialsPath(wantGoogleCredentialsPath) .withQuotaProject(wantQuotaProject) + .withRefreshStrategy(RefreshStrategy.REFRESH_AHEAD) .build(); assertThat(cc.hashCode()) @@ -230,6 +255,7 @@ public void testHashCode() { null, // googleCredentialsSupplier null, // googleCredentials wantGoogleCredentialsPath, - wantQuotaProject)); + wantQuotaProject, + RefreshStrategy.REFRESH_AHEAD)); } } diff --git a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectorTest.java b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectorTest.java index 8d895126..e9659c85 100644 --- a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectorTest.java +++ b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectorTest.java @@ -159,7 +159,7 @@ private Connector newConnector(ConnectorConfig config, MockAlloyDBAdminGrpc mock defaultExecutor, connectionInfoRepository, TestCertificates.INSTANCE.getClientKey(), - new DefaultConnectionInfoCacheFactory(), + new DefaultConnectionInfoCacheFactory(RefreshStrategy.REFRESH_AHEAD), new ConcurrentHashMap<>(), accessTokenSupplier, USER_AGENT); diff --git a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ITConnectorTest.java b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ITConnectorTest.java index 19e50802..5951eb0b 100644 --- a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ITConnectorTest.java +++ b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ITConnectorTest.java @@ -87,7 +87,7 @@ public void testConnect_createsSocketConnection() throws IOException { executor, connectionInfoRepo, RsaKeyPairGenerator.generateKeyPair(), - new DefaultConnectionInfoCacheFactory(), + new DefaultConnectionInfoCacheFactory(RefreshStrategy.REFRESH_AHEAD), new ConcurrentHashMap<>(), accessTokenSupplier, USER_AGENT); @@ -159,7 +159,7 @@ public void testConnect_whenTlsHandshakeFails() public void testEquals() { KeyPair clientConnectorKeyPair = RsaKeyPairGenerator.generateKeyPair(); DefaultConnectionInfoCacheFactory connectionInfoCacheFactory = - new DefaultConnectionInfoCacheFactory(); + new DefaultConnectionInfoCacheFactory(RefreshStrategy.REFRESH_AHEAD); ListeningScheduledExecutorService exec = MoreExecutors.listeningDecorator(Executors.newSingleThreadScheduledExecutor()); ConnectorConfig config = new ConnectorConfig.Builder().build(); @@ -236,7 +236,7 @@ public void testEquals() { executor, connectionInfoRepo, clientConnectorKeyPair, - new DefaultConnectionInfoCacheFactory(), // Different + new DefaultConnectionInfoCacheFactory(RefreshStrategy.REFRESH_AHEAD), // Different new ConcurrentHashMap<>(), accessTokenSupplier, USER_AGENT)); @@ -270,7 +270,7 @@ public void testEquals() { public void testHashCode() { KeyPair clientConnectorKeyPair = RsaKeyPairGenerator.generateKeyPair(); DefaultConnectionInfoCacheFactory connectionInfoCacheFactory = - new DefaultConnectionInfoCacheFactory(); + new DefaultConnectionInfoCacheFactory(RefreshStrategy.REFRESH_AHEAD); ConcurrentHashMap instances = new ConcurrentHashMap<>(); ConnectorConfig config = new ConnectorConfig.Builder().build(); diff --git a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/LazyConnectionInfoCacheTest.java b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/LazyConnectionInfoCacheTest.java new file mode 100644 index 00000000..c3da0a9d --- /dev/null +++ b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/LazyConnectionInfoCacheTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2025 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.alloydb; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.cloud.alloydb.v1alpha.InstanceName; +import java.security.KeyPair; +import java.security.cert.CertificateException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.operator.OperatorCreationException; +import org.junit.Test; + +public class LazyConnectionInfoCacheTest { + + private static final Instant ONE_HOUR_AGO = + Instant.now().minus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.SECONDS); + private static final Instant ONE_HOUR_FROM_NOW = + Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.SECONDS); + private static final Instant TWO_HOURS_FROM_NOW = + Instant.now().plus(2, ChronoUnit.HOURS).truncatedTo(ChronoUnit.SECONDS); + + private static final InstanceName TEST_INSTANCE_NAME = + InstanceName.parse( + "projects//locations//clusters//instances/"); + + private final KeyPair keyPair = RsaKeyPairGenerator.generateKeyPair(); + + @Test + public void testGetConnectionInfo() { + InMemoryConnectionInfoRepo repo = new InMemoryConnectionInfoRepo(); + repo.addResponses(() -> buildConnectionInfoWithClientCertExpiration(ONE_HOUR_FROM_NOW)); + LazyConnectionInfoCache cache = new LazyConnectionInfoCache(repo, TEST_INSTANCE_NAME, keyPair); + + ConnectionInfo connectionInfo = cache.getConnectionInfo(); + assertThat(connectionInfo.getClientCertificate().getNotAfter().toInstant()) + .isEqualTo(ONE_HOUR_FROM_NOW); + } + + @Test + public void testGetConnectionInfo_updatesCacheWhenCertificateExpires() { + InMemoryConnectionInfoRepo repo = new InMemoryConnectionInfoRepo(); + repo.addResponses( + () -> buildConnectionInfoWithClientCertExpiration(ONE_HOUR_AGO), + () -> buildConnectionInfoWithClientCertExpiration(ONE_HOUR_FROM_NOW)); + LazyConnectionInfoCache cache = new LazyConnectionInfoCache(repo, TEST_INSTANCE_NAME, keyPair); + + // seed internal cache with first response from connection info repo (an expired certificate). + ConnectionInfo connectionInfo = cache.getConnectionInfo(); + assertThat(connectionInfo.getClientCertificate().getNotAfter().toInstant()) + .isEqualTo(ONE_HOUR_AGO); + + connectionInfo = cache.getConnectionInfo(); + assertThat(connectionInfo.getClientCertificate().getNotAfter().toInstant()) + .isEqualTo(ONE_HOUR_FROM_NOW); + } + + @Test + public void testForceRefresh() { + InMemoryConnectionInfoRepo repo = new InMemoryConnectionInfoRepo(); + repo.addResponses( + () -> buildConnectionInfoWithClientCertExpiration(ONE_HOUR_FROM_NOW), + () -> buildConnectionInfoWithClientCertExpiration(TWO_HOURS_FROM_NOW)); + LazyConnectionInfoCache cache = new LazyConnectionInfoCache(repo, TEST_INSTANCE_NAME, keyPair); + + // seed the internal cache + ConnectionInfo connectionInfo = cache.getConnectionInfo(); + assertThat(connectionInfo.getClientCertificate().getNotAfter().toInstant()) + .isEqualTo(ONE_HOUR_FROM_NOW); + + cache.forceRefresh(); // invalidate the cache + + connectionInfo = cache.getConnectionInfo(); + assertThat(connectionInfo.getClientCertificate().getNotAfter().toInstant()) + .isEqualTo(TWO_HOURS_FROM_NOW); + } + + @Test + public void testClose() { + LazyConnectionInfoCache cache = + new LazyConnectionInfoCache(new InMemoryConnectionInfoRepo(), TEST_INSTANCE_NAME, keyPair); + + cache.close(); + + // After the cache is closed, subsequent usage throws an exception. + assertThrows(IllegalStateException.class, cache::getConnectionInfo); + assertThrows(IllegalStateException.class, cache::forceRefresh); + assertThrows(IllegalStateException.class, cache::refreshIfExpired); + } + + private ConnectionInfo buildConnectionInfoWithClientCertExpiration(Instant notAfter) + throws CertificateException, OperatorCreationException, CertIOException { + return new ConnectionInfo( + "10.0.0.1", + "34.0.0.1", + "", + "some-instance-id", + TestCertificates.INSTANCE.getEphemeralCertificate(keyPair.getPublic(), notAfter), + Arrays.asList( + TestCertificates.INSTANCE.getIntermediateCertificate(), + TestCertificates.INSTANCE.getRootCertificate()), + TestCertificates.INSTANCE.getRootCertificate()); + } +} diff --git a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectionInfoCacheTest.java b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/RefreshAheadConnectionInfoCacheTest.java similarity index 95% rename from alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectionInfoCacheTest.java rename to alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/RefreshAheadConnectionInfoCacheTest.java index b43692ca..30ae5163 100644 --- a/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/ConnectionInfoCacheTest.java +++ b/alloydb-jdbc-connector/src/test/java/com/google/cloud/alloydb/RefreshAheadConnectionInfoCacheTest.java @@ -37,7 +37,7 @@ import org.junit.Before; import org.junit.Test; -public class ConnectionInfoCacheTest { +public class RefreshAheadConnectionInfoCacheTest { private static final String TEST_INSTANCE_IP = "10.0.0.1"; private static final String TEST_INSTANCE_PUBLIC_IP = "34.0.0.1"; @@ -82,8 +82,8 @@ public void testGetConnectionInfo_returnsConnectionInfo() { TestCertificates.INSTANCE.getIntermediateCertificate(), TestCertificates.INSTANCE.getRootCertificate()), TestCertificates.INSTANCE.getRootCertificate())); - DefaultConnectionInfoCache connectionInfoCache = - new DefaultConnectionInfoCache( + RefreshAheadConnectionInfoCache connectionInfoCache = + new RefreshAheadConnectionInfoCache( MoreExecutors.listeningDecorator(executor), connectionInfoRepo, instanceName, @@ -142,8 +142,8 @@ public Object getTransportCode() { keyPair.getPublic(), ONE_HOUR_FROM_NOW), certificateChain, TestCertificates.INSTANCE.getRootCertificate())); - DefaultConnectionInfoCache connectionInfoCache = - new DefaultConnectionInfoCache( + RefreshAheadConnectionInfoCache connectionInfoCache = + new RefreshAheadConnectionInfoCache( MoreExecutors.listeningDecorator(executor), connectionInfoRepo, instanceName, @@ -189,8 +189,8 @@ public void testGetConnectionInfo_scheduledNextOperationImmediately_onCertificat keyPair.getPublic(), ONE_HOUR_FROM_NOW), certificateChain, TestCertificates.INSTANCE.getRootCertificate())); - DefaultConnectionInfoCache connectionInfoCache = - new DefaultConnectionInfoCache( + RefreshAheadConnectionInfoCache connectionInfoCache = + new RefreshAheadConnectionInfoCache( MoreExecutors.listeningDecorator(executor), connectionInfoRepo, instanceName, diff --git a/docs/configuration.md b/docs/configuration.md index 2680c741..a2ff2109 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -252,12 +252,13 @@ registered with `ConnectorRegistry.register()`. These properties configure the connector which loads AlloyDB instance configuration using the AlloyDB API. -| JDBC Connection Property | Description | Example | -|---------------|---------------------|-----------------| -| alloydbTargetPrincipal | The service account to impersonate when connecting to the database and database admin API. | `db-user@my-project.iam.gserviceaccount.com` | -| alloydbDelegates | A comma-separated list of service accounts delegates. See [Delegated Service Account Impersonation](jdbc.md#delegated-service-account-impersonation) | `application@my-project.iam.gserviceaccount.com,services@my-project.iam.gserviceaccount.com` | -| alloydbAdminServiceEndpoint | An alternate AlloyDB API endpoint. | `alloydb.googleapis.com:443` | -| alloydbGoogleCredentialsPath | A file path to a JSON file containing a GoogleCredentials oauth token.| `/home/alice/secrets/my-credentials.json` | +| JDBC Connection Property | Description | Example | +|---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------| +| alloydbTargetPrincipal | The service account to impersonate when connecting to the database and database admin API. | `db-user@my-project.iam.gserviceaccount.com` | +| alloydbDelegates | A comma-separated list of service accounts delegates. See [Delegated Service Account Impersonation](jdbc.md#delegated-service-account-impersonation) | `application@my-project.iam.gserviceaccount.com,services@my-project.iam.gserviceaccount.com` | +| alloydbAdminServiceEndpoint | An alternate AlloyDB API endpoint. | `alloydb.googleapis.com:443` | +| alloydbGoogleCredentialsPath | A file path to a JSON file containing a GoogleCredentials oauth token. | `/home/alice/secrets/my-credentials.json` | +| alloydbRefreshStrategy | Either `refresh_ahead` where certificates are refreshed in a background thread, or `lazy` where certificates are refreshed as needed. The `lazy` strategy is best when CPU isn't always available (e.g., Cloud Run) | ### Connection Configuration Properties @@ -266,4 +267,4 @@ These properties configure the connection to a specific AlloyDB instance. | JDBC Property Name |Description | Example | |------------------|---------------------|---------------------| | alloydbInstanceName (required) | The AlloyDB Instance database server. | `projects//locations//clusters//instances/` | -| alloydbNamedConnector | The name of the named connector created using `ConnectorRegistry.register()` | `my-configuration` | \ No newline at end of file +| alloydbNamedConnector | The name of the named connector created using `ConnectorRegistry.register()` | `my-configuration` |