Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for lazy refresh #606

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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 @@ -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);
rhatgadkar-goog marked this conversation as resolved.
Show resolved Hide resolved
}

@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;

rhatgadkar-goog marked this conversation as resolved.
Show resolved Hide resolved
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() {
nancynh marked this conversation as resolved.
Show resolved Hide resolved
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
Loading