Skip to content

Commit

Permalink
feat: Add lazy refresh strategy to the connector. Fixes #992.
Browse files Browse the repository at this point in the history
The lazy refresh strategy only refreshes credentials and certificate information when the application attempts
to establish a new database connection. On Cloud Run and other serverless runtimes, this is more reliable
than the default background refresh strategy.

Fixes #992
  • Loading branch information
hessjcg authored May 29, 2024
1 parent d97a93b commit d84d082
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 18 deletions.
18 changes: 16 additions & 2 deletions core/src/main/java/com/google/cloud/sql/core/Connector.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import com.google.cloud.sql.ConnectorConfig;
import com.google.cloud.sql.CredentialFactory;
import com.google.cloud.sql.RefreshStrategy;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningScheduledExecutorService;
import java.io.File;
Expand All @@ -26,6 +27,7 @@
import java.net.Socket;
import java.security.KeyPair;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import javax.net.ssl.SSLSocket;
import jnr.unixsocket.UnixSocketAddress;
import jnr.unixsocket.UnixSocketChannel;
Expand Down Expand Up @@ -154,9 +156,21 @@ ConnectionInfoCache getConnection(ConnectionConfig config) {
private ConnectionInfoCache createConnectionInfo(ConnectionConfig config) {
logger.debug(
String.format("[%s] Connection info added to cache.", config.getCloudSqlInstance()));
if (config.getConnectorConfig().getRefreshStrategy() == RefreshStrategy.LAZY) {
// Resolve the key operation immediately.
KeyPair keyPair = null;
try {
keyPair = localKeyPair.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
return new LazyRefreshConnectionInfoCache(
config, adminApi, instanceCredentialFactory, keyPair);

return new RefreshAheadConnectionInfoCache(
config, adminApi, instanceCredentialFactory, executor, localKeyPair, minRefreshDelayMs);
} else {
return new RefreshAheadConnectionInfoCache(
config, adminApi, instanceCredentialFactory, executor, localKeyPair, minRefreshDelayMs);
}
}

public void close() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright 2024 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.sql.core;

import com.google.cloud.sql.CredentialFactory;
import java.security.KeyPair;

/**
* Implements the lazy refresh cache strategy, which loads the new certificate as needed during a
* request for a new connection.
*/
class LazyRefreshConnectionInfoCache implements ConnectionInfoCache {
private final ConnectionConfig config;
private final CloudSqlInstanceName instanceName;

private final LazyRefreshStrategy refreshStrategy;

/**
* Initializes a new Cloud SQL instance based on the given connection name using the lazy refresh
* strategy.
*
* @param config instance connection name in the format "PROJECT_ID:REGION_ID:INSTANCE_ID"
* @param connectionInfoRepository Service class for interacting with the Cloud SQL Admin API
* @param keyPair public/private key pair used to authenticate connections
*/
public LazyRefreshConnectionInfoCache(
ConnectionConfig config,
ConnectionInfoRepository connectionInfoRepository,
CredentialFactory tokenSourceFactory,
KeyPair keyPair) {
this.config = config;
this.instanceName = new CloudSqlInstanceName(config.getCloudSqlInstance());

AccessTokenSupplier accessTokenSupplier =
DefaultAccessTokenSupplier.newInstance(config.getAuthType(), tokenSourceFactory);
CloudSqlInstanceName instanceName = new CloudSqlInstanceName(config.getCloudSqlInstance());

this.refreshStrategy =
new LazyRefreshStrategy(
config.getCloudSqlInstance(),
() ->
connectionInfoRepository.getConnectionInfoSync(
instanceName, accessTokenSupplier, config.getAuthType(), keyPair));
}

@Override
public ConnectionMetadata getConnectionMetadata(long timeoutMs) {
return refreshStrategy.getConnectionInfo(timeoutMs).toConnectionMetadata(config, instanceName);
}

@Override
public void forceRefresh() {
refreshStrategy.forceRefresh();
}

@Override
public void refreshIfExpired() {
refreshStrategy.refreshIfExpired();
}

@Override
public void close() {
refreshStrategy.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,18 @@ private void fetchConnectionInfo() {
logger.debug(String.format("[%s] Lazy Refresh Operation: Starting refresh operation.", name));
try {
this.connectionInfo = this.refreshOperation.get();
logger.debug(
String.format(
"[%s] Lazy Refresh Operation: Completed refresh with new certificate "
+ "expiration at %s.",
name, connectionInfo.getExpiration().toString()));

} catch (TerminalException e) {
logger.debug(String.format("[%s] Lazy Refresh Operation: Failed! No retry.", name), e);
throw e;
} catch (Exception e) {
throw new RuntimeException(String.format("[%s] Refresh Operation: Failed!", name), e);
}

logger.debug(
String.format(
"[%s] Lazy Refresh Operation: Completed refresh with new certificate "
+ "expiration at %s.",
name, connectionInfo.getExpiration().toString()));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2024 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.sql.core;

import static com.google.common.truth.Truth.assertThat;

import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.security.KeyPair;
import java.util.concurrent.ExecutionException;
import org.junit.Before;
import org.junit.Test;

public class LazyRefreshConnectionInfoCacheTest {
private ListenableFuture<KeyPair> keyPairFuture;
private final StubCredentialFactory stubCredentialFactory =
new StubCredentialFactory("my-token", System.currentTimeMillis() + 3600L);

@Before
public void setup() throws Exception {
MockAdminApi mockAdminApi = new MockAdminApi();
this.keyPairFuture = Futures.immediateFuture(mockAdminApi.getClientKeyPair());
}

@Test
public void testCloudSqlInstanceDataLazyStrategyRetrievedSuccessfully()
throws ExecutionException, InterruptedException {
KeyPair kp = keyPairFuture.get();
TestDataSupplier instanceDataSupplier = new TestDataSupplier(false);

// initialize connectionInfoCache after mocks are set up
LazyRefreshConnectionInfoCache connectionInfoCache =
new LazyRefreshConnectionInfoCache(
new ConnectionConfig.Builder().withCloudSqlInstance("project:region:instance").build(),
instanceDataSupplier,
stubCredentialFactory,
kp);

ConnectionMetadata gotMetadata = connectionInfoCache.getConnectionMetadata(300);
ConnectionMetadata gotMetadata2 = connectionInfoCache.getConnectionMetadata(300);

// Assert that the underlying ConnectionInfo was retrieved exactly once.
assertThat(instanceDataSupplier.counter.get()).isEqualTo(1);

// Assert that the ConnectionInfo fields are added to ConnectionMetadata
assertThat(gotMetadata.getKeyManagerFactory())
.isSameInstanceAs(instanceDataSupplier.response.getSslData().getKeyManagerFactory());
assertThat(gotMetadata.getKeyManagerFactory())
.isSameInstanceAs(gotMetadata2.getKeyManagerFactory());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,6 @@ public ConnectionInfo getConnectionInfoSync(
throw new RuntimeException("Flaky");
}
successCounter.incrementAndGet();
return null;
return response;
}
}
20 changes: 11 additions & 9 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,20 +273,22 @@ registered with `ConnectorRegistry.register()`.
These properties configure the connector which loads Cloud SQL instance
configuration using the Cloud SQL Admin API.

| JDBC Connection Property | R2DBC Property Name | Description | Example |
|-------------------------------|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
| cloudSqlTargetPrincipal | TARGET_PRINCIPAL | The service account to impersonate when connecting to the database and database admin API. | `db-user@my-project.iam.gserviceaccount.com` |
| cloudSqlDelegates | DELEGATES | 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` |
| cloudSqlGoogleCredentialsPath | GOOGLE_CREDENTIALS_PATH | A file path to a JSON file containing a GoogleCredentials oauth token. | `/home/alice/secrets/my-credentials.json` |
| cloudSqlAdminRootUrl | ADMIN_ROOT_URL | An alternate root url for the Cloud SQL admin API. Must end in '/' See [rootUrl](java-api-root-url) | `https://googleapis.example.com/` |
| cloudSqlAdminServicePath | ADMIN_SERVICE_PATH | An alternate path to the SQL Admin API endpoint. Must not begin with '/'. Must end with '/'. See [servicePath](java-api-service-path) | `sqladmin/v1beta1/` |
| cloudSqlAdminQuotaProject | ADMIN_QUOTA_PROJECT | A project ID for quota and billing. See [Quota Project][quota-project] | `my-project` |
| cloudSqlUniverseDomain | UNIVERSE_DOMAIN | A universe domain for the TPC environment (default is googleapis.com). See [TPC][tpc] | test-universe.test
| JDBC Connection Property | R2DBC Property Name | Description | Example |
|-------------------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
| cloudSqlTargetPrincipal | TARGET_PRINCIPAL | The service account to impersonate when connecting to the database and database admin API. | `db-user@my-project.iam.gserviceaccount.com` |
| cloudSqlDelegates | DELEGATES | 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` |
| cloudSqlGoogleCredentialsPath | GOOGLE_CREDENTIALS_PATH | A file path to a JSON file containing a GoogleCredentials oauth token. | `/home/alice/secrets/my-credentials.json` |
| cloudSqlAdminRootUrl | ADMIN_ROOT_URL | An alternate root url for the Cloud SQL admin API. Must end in '/' See [rootUrl](java-api-root-url) | `https://googleapis.example.com/` |
| cloudSqlAdminServicePath | ADMIN_SERVICE_PATH | An alternate path to the SQL Admin API endpoint. Must not begin with '/'. Must end with '/'. See [servicePath](java-api-service-path) | `sqladmin/v1beta1/` |
| cloudSqlAdminQuotaProject | ADMIN_QUOTA_PROJECT | A project ID for quota and billing. See [Quota Project][quota-project] | `my-project` |
| cloudSqlUniverseDomain | UNIVERSE_DOMAIN | A universe domain for the TPC environment (default is googleapis.com). See [TPC][tpc] | test-universe.test |
| cloudSqlRefreshStrategy | REFRESH_STRATEGY | The strategy used to refresh the Google Cloud SQL authentication tokens. Valid values: `background` - refresh credentials using a background thread, `lazy` - refresh credentials during connection attempts. [Refresh Strategy][refresh-strategy] | `lazy` |

[java-api-root-url]: https://github.com/googleapis/google-api-java-client/blob/main/google-api-client/src/main/java/com/google/api/client/googleapis/services/AbstractGoogleClient.java#L49
[java-api-service-path]: https://github.com/googleapis/google-api-java-client/blob/main/google-api-client/src/main/java/com/google/api/client/googleapis/services/AbstractGoogleClient.java#L52
[quota-project]: jdbc.md#quota-project
[tpc]: jdbc.md#trusted-partner-cloud-tpc-support
[refresh-strategy]: jdbc.md#refresh-strategy

### Connection Configuration Properties

Expand Down
18 changes: 18 additions & 0 deletions docs/jdbc.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,24 @@ Properties connProps = new Properties();
connProps.setProperty("cloudSqlUniverseDomain", "test-universe.test");
```


### Refresh Strategy for Serverless Compute

When the connector runs in Cloud Run, App Engine Flex, or other serverless
compute platforms, the connector should be configured to use the `lazy` refresh
strategy instead of the default `background` strategy.

Cloud Run, Flex, and other serverless compute platforms throttle application CPU
in a way that interferes with the default `background` strategy used to refresh
the client certificate and authentication token.

#### Example

```java
Properties connProps = new Properties();
connProps.setProperty("cloudSqlRefreshStrategy", "lazy");
```

## Configuration Reference

- See [Configuration Reference](configuration.md)
Expand Down

0 comments on commit d84d082

Please sign in to comment.