Skip to content

Commit b9526bb

Browse files
feat: add support for lazy refresh strategy (#1093)
Add refresh_strategy argument to Connector() that allows setting the strategy to "lazy" to use a lazy refresh strategy. When creating a Connector via Connector(refresh_strategy="lazy"), the connection info and ephemeral certificate will be refreshed only when the cache certificate has expired. No background tasks run periodically with this option, making it ideal for use in serverless environments such as Cloud Run, Cloud Functions, etc, where the CPU may be throttled.
1 parent b0c699e commit b9526bb

File tree

7 files changed

+313
-55
lines changed

7 files changed

+313
-55
lines changed

README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,15 @@ defaults for each connection to make, you can initialize a
185185
`Connector` object as follows:
186186

187187
```python
188-
from google.cloud.sql.connector import Connector, IPTypes
188+
from google.cloud.sql.connector import Connector
189189

190190
# Note: all parameters below are optional
191191
connector = Connector(
192192
ip_type="public", # can also be "private" or "psc"
193193
enable_iam_auth=False,
194194
timeout=30,
195-
credentials=custom_creds # google.auth.credentials.Credentials
195+
credentials=custom_creds, # google.auth.credentials.Credentials
196+
refresh_strategy="lazy", # can be "lazy" or "background"
196197
)
197198
```
198199

@@ -254,6 +255,21 @@ with Connector() as connector:
254255
print(row)
255256
```
256257

258+
### Configuring a Lazy Refresh (Cloud Run, Cloud Functions etc.)
259+
260+
The Connector's `refresh_strategy` argument can be set to `"lazy"` to configure
261+
the Python Connector to retrieve connection info lazily and as-needed.
262+
Otherwise, a background refresh cycle runs to retrive the connection info
263+
periodically. This setting is useful in environments where the CPU may be
264+
throttled outside of a request context, e.g., Cloud Run, Cloud Functions, etc.
265+
266+
To set the refresh strategy, set the `refresh_strategy` keyword argument when
267+
initializing a `Connector`:
268+
269+
```python
270+
connector = Connector(refresh_strategy="lazy")
271+
```
272+
257273
### Specifying IP Address Type
258274

259275
The Cloud SQL Python Connector can be used to connect to Cloud SQL instances
@@ -277,7 +293,7 @@ conn = connector.connect(
277293
```
278294

279295
> [!IMPORTANT]
280-
>
296+
>
281297
> If specifying Private IP or Private Service Connect (PSC), your application must be
282298
> attached to the proper VPC network to connect to your Cloud SQL instance. For most
283299
> applications this will require the use of a [VPC Connector][vpc-connector].
@@ -355,6 +371,14 @@ The Python Connector can be used alongside popular Python web frameworks such
355371
as Flask, FastAPI, etc, to integrate Cloud SQL databases within your
356372
web applications.
357373

374+
> [!NOTE]
375+
>
376+
> For serverless environments such as Cloud Functions, Cloud Run, etc, it may be
377+
> beneficial to initialize the `Connector` with the lazy refresh strategy.
378+
> i.e. `Connector(refresh_strategy="lazy")`
379+
>
380+
> See [Configuring a Lazy Refresh](#configuring-a-lazy-refresh-cloud-run-cloud-functions-etc)
381+
358382
#### Flask-SQLAlchemy
359383

360384
[Flask-SQLAlchemy](https://flask-sqlalchemy.palletsprojects.com/en/2.x/)

google/cloud/sql/connector/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
from google.cloud.sql.connector.connector import Connector
1818
from google.cloud.sql.connector.connector import create_async_connector
1919
from google.cloud.sql.connector.instance import IPTypes
20+
from google.cloud.sql.connector.instance import RefreshStrategy
2021
from google.cloud.sql.connector.version import __version__
2122

22-
__all__ = ["__version__", "create_async_connector", "Connector", "IPTypes"]
23+
__all__ = [
24+
"__version__",
25+
"create_async_connector",
26+
"Connector",
27+
"IPTypes",
28+
"RefreshStrategy",
29+
]

google/cloud/sql/connector/connector.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import socket
2323
from threading import Thread
2424
from types import TracebackType
25-
from typing import Any, Dict, Optional, Type
25+
from typing import Any, Dict, Optional, Type, Union
2626

2727
import google.auth
2828
from google.auth.credentials import Credentials
@@ -34,6 +34,8 @@
3434
from google.cloud.sql.connector.exceptions import DnsNameResolutionError
3535
from google.cloud.sql.connector.instance import IPTypes
3636
from google.cloud.sql.connector.instance import RefreshAheadCache
37+
from google.cloud.sql.connector.instance import RefreshStrategy
38+
from google.cloud.sql.connector.lazy import LazyRefreshCache
3739
import google.cloud.sql.connector.pg8000 as pg8000
3840
import google.cloud.sql.connector.pymysql as pymysql
3941
import google.cloud.sql.connector.pytds as pytds
@@ -62,6 +64,7 @@ def __init__(
6264
sqladmin_api_endpoint: Optional[str] = None,
6365
user_agent: Optional[str] = None,
6466
universe_domain: Optional[str] = None,
67+
refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
6568
) -> None:
6669
"""Initializes a Connector instance.
6770
@@ -98,6 +101,11 @@ def __init__(
98101
universe_domain (str): The universe domain for Cloud SQL API calls.
99102
Default: "googleapis.com".
100103
104+
refresh_strategy (str | RefreshStrategy): The default refresh strategy
105+
used to refresh SSL/TLS cert and instance metadata. Can be one
106+
of the following: RefreshStrategy.LAZY ("LAZY") or
107+
RefreshStrategy.BACKGROUND ("BACKGROUND").
108+
Default: RefreshStrategy.BACKGROUND
101109
"""
102110
# if event loop is given, use for background tasks
103111
if loop:
@@ -113,7 +121,7 @@ def __init__(
113121
asyncio.run_coroutine_threadsafe(generate_keys(), self._loop),
114122
loop=self._loop,
115123
)
116-
self._cache: Dict[str, RefreshAheadCache] = {}
124+
self._cache: Dict[str, Union[RefreshAheadCache, LazyRefreshCache]] = {}
117125
self._client: Optional[CloudSQLClient] = None
118126

119127
# initialize credentials
@@ -139,6 +147,10 @@ def __init__(
139147
if isinstance(ip_type, str):
140148
ip_type = IPTypes._from_str(ip_type)
141149
self._ip_type = ip_type
150+
# if refresh_strategy is str, convert to RefreshStrategy enum
151+
if isinstance(refresh_strategy, str):
152+
refresh_strategy = RefreshStrategy._from_str(refresh_strategy)
153+
self._refresh_strategy = refresh_strategy
142154
self._universe_domain = universe_domain
143155
# construct service endpoint for Cloud SQL Admin API calls
144156
if not sqladmin_api_endpoint:
@@ -265,12 +277,20 @@ async def connect_async(
265277
"connector.Connector object."
266278
)
267279
else:
268-
cache = RefreshAheadCache(
269-
instance_connection_string,
270-
self._client,
271-
self._keys,
272-
enable_iam_auth,
273-
)
280+
if self._refresh_strategy == RefreshStrategy.LAZY:
281+
cache = LazyRefreshCache(
282+
instance_connection_string,
283+
self._client,
284+
self._keys,
285+
enable_iam_auth,
286+
)
287+
else:
288+
cache = RefreshAheadCache(
289+
instance_connection_string,
290+
self._client,
291+
self._keys,
292+
enable_iam_auth,
293+
)
274294
self._cache[instance_connection_string] = cache
275295

276296
connect_func = {

google/cloud/sql/connector/instance.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ def _parse_instance_connection_name(connection_name: str) -> Tuple[str, str, str
5252
return connection_name_split[1], connection_name_split[3], connection_name_split[4]
5353

5454

55+
class RefreshStrategy(Enum):
56+
LAZY: str = "LAZY"
57+
BACKGROUND: str = "BACKGROUND"
58+
59+
@classmethod
60+
def _missing_(cls, value: object) -> None:
61+
raise ValueError(
62+
f"Incorrect value for refresh_strategy, got '{value}'. Want one of: "
63+
f"{', '.join([repr(m.value) for m in cls])}."
64+
)
65+
66+
@classmethod
67+
def _from_str(cls, refresh_strategy: str) -> RefreshStrategy:
68+
"""Convert refresh strategy from a str into RefreshStrategy."""
69+
return cls(refresh_strategy.upper())
70+
71+
5572
class IPTypes(Enum):
5673
PUBLIC: str = "PRIMARY"
5774
PRIVATE: str = "PRIVATE"

google/cloud/sql/connector/lazy.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Copyright 2024 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
from datetime import datetime
17+
from datetime import timedelta
18+
from datetime import timezone
19+
import logging
20+
from typing import Optional
21+
22+
from google.cloud.sql.connector.client import CloudSQLClient
23+
from google.cloud.sql.connector.connection_info import ConnectionInfo
24+
from google.cloud.sql.connector.instance import _parse_instance_connection_name
25+
from google.cloud.sql.connector.refresh_utils import _refresh_buffer
26+
27+
logger = logging.getLogger(name=__name__)
28+
29+
30+
class LazyRefreshCache:
31+
"""Cache that refreshes connection info when a caller requests a connection.
32+
33+
Only refreshes the cache when a new connection is requested and the current
34+
certificate is close to or already expired.
35+
36+
This is the recommended option for serverless environments.
37+
"""
38+
39+
def __init__(
40+
self,
41+
instance_connection_string: str,
42+
client: CloudSQLClient,
43+
keys: asyncio.Future,
44+
enable_iam_auth: bool = False,
45+
) -> None:
46+
"""Initializes a LazyRefreshCache instance.
47+
48+
Args:
49+
instance_connection_string (str): The Cloud SQL Instance's
50+
connection string (also known as an instance connection name).
51+
client (CloudSQLClient): The Cloud SQL Client instance.
52+
keys (asyncio.Future): A future to the client's public-private key
53+
pair.
54+
enable_iam_auth (bool): Enables automatic IAM database authentication
55+
(Postgres and MySQL) as the default authentication method for all
56+
connections.
57+
"""
58+
# validate and parse instance connection name
59+
self._project, self._region, self._instance = _parse_instance_connection_name(
60+
instance_connection_string
61+
)
62+
self._instance_connection_string = instance_connection_string
63+
64+
self._enable_iam_auth = enable_iam_auth
65+
self._keys = keys
66+
self._client = client
67+
self._lock = asyncio.Lock()
68+
self._cached: Optional[ConnectionInfo] = None
69+
self._needs_refresh = False
70+
71+
async def force_refresh(self) -> None:
72+
"""
73+
Invalidates the cache and configures the next call to
74+
connect_info() to retrieve a fresh ConnectionInfo instance.
75+
"""
76+
async with self._lock:
77+
self._needs_refresh = True
78+
79+
async def connect_info(self) -> ConnectionInfo:
80+
"""Retrieves ConnectionInfo instance for establishing a secure
81+
connection to the Cloud SQL instance.
82+
"""
83+
async with self._lock:
84+
# If connection info is cached, check expiration.
85+
# Pad expiration with a buffer to give the client plenty of time to
86+
# establish a connection to the server with the certificate.
87+
if (
88+
self._cached
89+
and not self._needs_refresh
90+
and datetime.now(timezone.utc)
91+
< (self._cached.expiration - timedelta(seconds=_refresh_buffer))
92+
):
93+
logger.debug(
94+
f"['{self._instance_connection_string}']: Connection info "
95+
"is still valid, using cached info"
96+
)
97+
return self._cached
98+
logger.debug(
99+
f"['{self._instance_connection_string}']: Connection info "
100+
"refresh operation started"
101+
)
102+
try:
103+
conn_info = await self._client.get_connection_info(
104+
self._project,
105+
self._region,
106+
self._instance,
107+
self._keys,
108+
self._enable_iam_auth,
109+
)
110+
except Exception as e:
111+
logger.debug(
112+
f"['{self._instance_connection_string}']: Connection info "
113+
f"refresh operation failed: {str(e)}"
114+
)
115+
raise
116+
logger.debug(
117+
f"['{self._instance_connection_string}']: Connection info "
118+
"refresh operation completed successfully"
119+
)
120+
logger.debug(
121+
f"['{self._instance_connection_string}']: Current certificate "
122+
f"expiration = {str(conn_info.expiration)}"
123+
)
124+
self._cached = conn_info
125+
self._needs_refresh = False
126+
return conn_info
127+
128+
async def close(self) -> None:
129+
"""Close is a no-op and provided purely for a consistent interface with
130+
other cache types.
131+
"""
132+
pass

0 commit comments

Comments
 (0)