Skip to content

Commit

Permalink
feat: add support for lazy refresh
Browse files Browse the repository at this point in the history
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
  • Loading branch information
enocom committed Jan 13, 2025
1 parent 03be3a5 commit 17203e4
Show file tree
Hide file tree
Showing 17 changed files with 367 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String> 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(
Expand All @@ -84,6 +87,7 @@ static ConnectionConfig fromConnectionProperties(Properties props) {
.withAdminServiceEndpoint(adminServiceEndpoint)
.withGoogleCredentialsPath(googleCredentialsPath)
.withQuotaProject(quotaProject)
.withRefreshStrategy(refreshStrategy)
.build());
}

Expand All @@ -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/<PROJECT>/locations/<REGION>/clusters/<CLUSTER>/instances/<INSTANCE>",
ALLOYDB_INSTANCE_NAME));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,14 +44,16 @@ private ConnectorConfig(
Supplier<GoogleCredentials> googleCredentialsSupplier,
GoogleCredentials googleCredentials,
String googleCredentialsPath,
String quotaProject) {
String quotaProject,
RefreshStrategy refreshStrategy) {
this.targetPrincipal = targetPrincipal;
this.delegates = delegates;
this.adminServiceEndpoint = adminServiceEndpoint;
this.googleCredentialsSupplier = googleCredentialsSupplier;
this.googleCredentials = googleCredentials;
this.googleCredentialsPath = googleCredentialsPath;
this.quotaProject = quotaProject;
this.refreshStrategy = refreshStrategy;
}

@Override
Expand All @@ -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
Expand All @@ -80,7 +84,8 @@ public int hashCode() {
googleCredentialsSupplier,
googleCredentials,
googleCredentialsPath,
quotaProject);
quotaProject,
refreshStrategy);
}

public String getTargetPrincipal() {
Expand Down Expand Up @@ -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 {

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -183,7 +199,8 @@ public ConnectorConfig build() {
googleCredentialsSupplier,
googleCredentials,
googleCredentialsPath,
quotaProject);
quotaProject,
refreshStrategy);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ private Connector createConnector(ConnectorConfig config) {
executor,
connectionInfoRepository,
RsaKeyPairGenerator.generateKeyPair(),
new DefaultConnectionInfoCacheFactory(),
new DefaultConnectionInfoCacheFactory(config.getRefreshStrategy()),
new ConcurrentHashMap<>(),
accessTokenSupplier,
getUserAgents());
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConnectionInfo> 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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.google.cloud.alloydb;

// [START alloydb_hikaricp_connect_connector]

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

Expand Down
Loading

0 comments on commit 17203e4

Please sign in to comment.