Skip to content

Commit 6bf4fb0

Browse files
gabepescoGabe Pescoclaude
authored
fix(databricks): run U2M OAuth queries concurrently with a shared login (#6188)
Signed-off-by: Gabe Pesco <PescoG@medinsight.milliman.com> Co-authored-by: Gabe Pesco <PescoG@medinsight.milliman.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d943714 commit 6bf4fb0

6 files changed

Lines changed: 558 additions & 12 deletions

File tree

‎docs/integrations/engines/databricks.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ The only relevant SQLMesh configuration parameter is the optional `catalog` para
270270
| `force_databricks_connect` | When running locally, force the use of Databricks Connect for all model operations (so don't use SQL Connector for SQL models) | bool | N |
271271
| `disable_databricks_connect` | When running locally, disable the use of Databricks Connect for all model operations (so use SQL Connector for all models) | bool | N |
272272
| `disable_spark_session` | Do not use SparkSession if it is available (like when running in a notebook). | bool | N |
273+
| `force_shared_connection` | SQL Connector Only: Run all queries over a single connection instead of one per thread. With U2M OAuth, use this if the connector opens connections on its kernel backend (Reyden / Real-Time warehouses), which runs a separate browser login for each connection.| bool | N |
273274

274275
### Query tags
275276

‎sqlmesh/core/config/connection.py‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,7 @@ class DatabricksConnectionConfig(ConnectionConfig):
823823
force_databricks_connect: bool = False
824824
disable_databricks_connect: bool = False
825825
disable_spark_session: bool = False
826+
force_shared_connection: bool = False
826827

827828
concurrent_tasks: int = 1
828829
register_comments: bool = True
@@ -839,8 +840,27 @@ class DatabricksConnectionConfig(ConnectionConfig):
839840
@computed_field # type: ignore[prop-decorator]
840841
@property
841842
def shared_connection(self) -> bool:
842-
"""The connection should only be shared if U2M OAuth is being used"""
843-
return self.auth_type is not None and self.oauth_client_secret is None
843+
"""U2M OAuth connections share one login, so each thread can have its own connection. When the login can't
844+
be shared, or `force_shared_connection` is set, a single connection is shared so only one browser flow runs."""
845+
return self.force_shared_connection or (
846+
self._uses_u2m_oauth and not self._uses_shared_u2m_login
847+
)
848+
849+
@property
850+
def _uses_shared_u2m_login(self) -> bool:
851+
"""Whether every connection is given the same U2M OAuth login, letting each thread have its own connection."""
852+
from sqlmesh.utils.databricks import supports_shared_u2m_login
853+
854+
return bool(
855+
self._uses_u2m_oauth
856+
and self.server_hostname
857+
and not self.force_shared_connection
858+
and supports_shared_u2m_login()
859+
)
860+
861+
@property
862+
def _uses_u2m_oauth(self) -> bool:
863+
return bool(self.auth_type and "oauth" in self.auth_type and not self.oauth_client_secret)
844864

845865
@model_validator(mode="before")
846866
def _databricks_connect_validator(cls, data: t.Any) -> t.Any:
@@ -992,6 +1012,19 @@ def _static_connection_kwargs(self) -> t.Dict[str, t.Any]:
9921012
else:
9931013
# if auth_type is set to an 'oauth' type but no client_id/secret are set, then we are using U2M
9941014
# ref: https://docs.databricks.com/en/dev-tools/python-sql-connector.html#oauth-user-to-machine-u2m-authentication
1015+
# All connections share one login so that each thread can have its own connection without
1016+
# running its own browser flow. `credentials_provider` takes precedence over `auth_type` on the
1017+
# connector's Thrift and SEA backends. `auth_type` is still passed for the connector's kernel
1018+
# backend, which ignores `credentials_provider` and runs its own U2M flow for each connection
1019+
# (`force_shared_connection` avoids that by only opening one connection).
1020+
from sqlmesh.utils.databricks import get_shared_u2m_credentials_provider
1021+
1022+
if self._uses_shared_u2m_login:
1023+
if t.TYPE_CHECKING:
1024+
assert self.server_hostname is not None
1025+
conn_kwargs["credentials_provider"] = get_shared_u2m_credentials_provider(
1026+
self.server_hostname, self.auth_type
1027+
)
9951028
conn_kwargs["auth_type"] = self.auth_type
9961029

9971030
return conn_kwargs

‎sqlmesh/utils/databricks.py‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
import logging
5+
import re
6+
import typing as t
7+
from functools import lru_cache
8+
from threading import Lock
9+
10+
logger = logging.getLogger(__name__)
11+
12+
HeaderFactory = t.Callable[[], t.Dict[str, str]]
13+
14+
15+
class SharedU2MCredentialsProvider:
16+
"""A Databricks SQL connector `credentials_provider` that shares one U2M OAuth login across connections.
17+
18+
Without this, every `databricks.sql.connect()` call builds its own OAuth provider. For U2M OAuth that means
19+
each thread runs its own browser flow, and these race on the CSRF `state` parameter
20+
(https://github.com/tobymao/sqlmesh/issues/5646). Even if the browser flow is only run once, each connection
21+
would still hold its own copy of the refresh token. Databricks can issue single-use refresh tokens, so the
22+
first connection to refresh would invalidate the token held by every other connection.
23+
24+
Instead, a single connector OAuth provider is created lazily (behind a lock, so the browser flow only runs once)
25+
and every connection reads its headers from it. Reads are also serialized so only one thread can refresh the
26+
token at a time. This lets each thread have its own connection while sharing a single login.
27+
28+
If reading the token fails (for example because the refresh token expired or was revoked) the login is
29+
discarded, so the next connection or request logs in again rather than failing for the rest of the process.
30+
Network failures while refreshing keep the login, so the next request retries the refresh without a browser flow.
31+
"""
32+
33+
def __init__(self, server_hostname: str, auth_type: str):
34+
self._server_hostname = server_hostname
35+
self._auth_type = auth_type
36+
self._auth_provider: t.Optional[t.Any] = None
37+
self._lock = Lock()
38+
# Incremented when a login attempt finishes, so threads that waited on a failed attempt can tell it happened
39+
self._login_attempts_finished = 0
40+
self._login_error: t.Optional[Exception] = None
41+
42+
def auth_type(self) -> str:
43+
return self._auth_type
44+
45+
def __call__(self, *args: t.Any, **kwargs: t.Any) -> HeaderFactory:
46+
"""Called by the connector once per connection. Logs in if needed and returns that connection's header factory."""
47+
login_attempts_seen = self._login_attempts_finished
48+
with self._lock:
49+
self._ensure_logged_in(login_attempts_seen)
50+
return self._headers
51+
52+
def _headers(self) -> t.Dict[str, str]:
53+
"""Returns the auth headers for one request, refreshing the token if it has expired."""
54+
from databricks.sql.exc import RequestError
55+
56+
headers: t.Dict[str, str] = {}
57+
login_attempts_seen = self._login_attempts_finished
58+
with self._lock:
59+
auth_provider = self._ensure_logged_in(login_attempts_seen)
60+
try:
61+
auth_provider.add_headers(headers)
62+
except RequestError:
63+
# A network failure while refreshing leaves the login intact, since the connector only replaces its
64+
# tokens after a successful refresh, so the next request retries the refresh rather than logging in
65+
raise
66+
except Exception:
67+
self._auth_provider = None
68+
raise
69+
return headers
70+
71+
def _ensure_logged_in(self, login_attempts_seen: int) -> t.Any:
72+
"""Returns the shared connector auth provider, logging in if needed. Must be called with the lock held."""
73+
if self._auth_provider is not None:
74+
return self._auth_provider
75+
76+
# A login attempt failed while this thread was waiting on the lock. Re-raise its error rather than opening
77+
# another browser window for every waiting thread; callers that arrive afterwards will try again.
78+
if self._login_attempts_finished != login_attempts_seen and self._login_error is not None:
79+
raise self._login_error
80+
81+
try:
82+
self._auth_provider = self._create_auth_provider()
83+
self._login_error = None
84+
except Exception as e:
85+
self._login_error = e
86+
raise
87+
finally:
88+
self._login_attempts_finished += 1
89+
return self._auth_provider
90+
91+
def _create_auth_provider(self) -> t.Any:
92+
# These are internal to the connector, but using them means the OAuth flow is identical to the one the
93+
# connector runs when `auth_type` is passed to `connect()` (same client ID, redirect ports, and scopes)
94+
from databricks.sql import __version__
95+
from databricks.sql.auth.auth import get_python_sql_connector_auth_provider
96+
from databricks.sql.common.unified_http_client import UnifiedHttpClient
97+
from databricks.sql.utils import build_client_context
98+
99+
# The provider outlives any single connection, so it gets its own HTTP client rather than borrowing one
100+
# that would be closed along with its connection
101+
http_client = UnifiedHttpClient(
102+
build_client_context(self._server_hostname, __version__, user_agent_entry="sqlmesh")
103+
)
104+
return get_python_sql_connector_auth_provider(
105+
self._server_hostname, http_client=http_client, auth_type=self._auth_type
106+
)
107+
108+
109+
@lru_cache(maxsize=1)
110+
def supports_shared_u2m_login() -> bool:
111+
"""Whether the connector internals `SharedU2MCredentialsProvider` builds on accept the arguments it passes them.
112+
113+
These internals aren't public, so a connector upgrade could change them. If one has, U2M OAuth falls back to
114+
sharing a single connection rather than failing to connect.
115+
"""
116+
try:
117+
from databricks.sql import __version__
118+
except ImportError:
119+
# The connector isn't installed, so there are no connections to share a login across
120+
return False
121+
122+
try:
123+
from databricks.sql.auth.auth import get_python_sql_connector_auth_provider
124+
from databricks.sql.common.unified_http_client import UnifiedHttpClient
125+
from databricks.sql.exc import RequestError # noqa: F401
126+
from databricks.sql.utils import build_client_context
127+
128+
inspect.signature(build_client_context).bind(
129+
"hostname", __version__, user_agent_entry="sqlmesh"
130+
)
131+
inspect.signature(UnifiedHttpClient).bind(None)
132+
inspect.signature(get_python_sql_connector_auth_provider).bind(
133+
"hostname", http_client=None, auth_type="databricks-oauth"
134+
)
135+
except Exception as e:
136+
logger.warning(
137+
"Databricks SQL connector %s is not compatible with SQLMesh's shared U2M OAuth login (%s). U2M OAuth "
138+
"connections will share a single connection, so queries will run one at a time.",
139+
__version__,
140+
e,
141+
)
142+
return False
143+
return True
144+
145+
146+
_shared_u2m_credentials_providers: t.Dict[t.Tuple[str, str], SharedU2MCredentialsProvider] = {}
147+
_shared_u2m_credentials_providers_lock = Lock()
148+
149+
150+
def get_shared_u2m_credentials_provider(
151+
server_hostname: str, auth_type: str
152+
) -> SharedU2MCredentialsProvider:
153+
"""Returns the process-wide U2M credentials provider for the given workspace and auth type.
154+
155+
Engine adapters and config copies are created several times per process, so the provider is kept at module
156+
level to avoid prompting the user to log in more than once. U2M OAuth is an interactive, single-user login, so
157+
every connection to a workspace in this process uses the identity of whoever completed the browser flow.
158+
"""
159+
# The same workspace can be written with or without a scheme or trailing slash
160+
normalized_hostname = re.sub(r"^https?://", "", server_hostname.strip(), flags=re.I)
161+
normalized_hostname = normalized_hostname.rstrip("/").lower()
162+
key = (normalized_hostname, auth_type)
163+
with _shared_u2m_credentials_providers_lock:
164+
if key not in _shared_u2m_credentials_providers:
165+
_shared_u2m_credentials_providers[key] = SharedU2MCredentialsProvider(
166+
normalized_hostname, auth_type
167+
)
168+
return _shared_u2m_credentials_providers[key]

‎tests/cli/test_cli.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2269,7 +2269,7 @@ def test_init_project_engine_configs(tmp_path):
22692269
"redshift": "# concurrent_tasks: 4\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # user: \n # password: \n # database: \n # host: \n # port: \n # source_address: \n # unix_sock: \n # ssl: \n # sslmode: \n # timeout: \n # tcp_keepalive: \n # application_name: \n # preferred_role: \n # principal_arn: \n # credentials_provider: \n # region: \n # cluster_identifier: \n # iam: \n # db_user: \n # is_serverless: \n # serverless_acct_id: \n # serverless_work_group: \n # enable_merge: ",
22702270
"bigquery": "# concurrent_tasks: 1\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # method: oauth\n # project: \n # execution_project: \n # quota_project: \n # location: \n # keyfile: \n # keyfile_json: \n # token: \n # refresh_token: \n # client_id: \n # client_secret: \n # token_uri: \n # scopes: \n # impersonated_service_account: \n # job_creation_timeout_seconds: \n # job_execution_timeout_seconds: \n # job_retries: 1\n # job_retry_deadline_seconds: \n # priority: \n # maximum_bytes_billed: \n # reservation: ",
22712271
"snowflake": "account: \n # concurrent_tasks: 4\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # user: \n # password: \n # warehouse: \n # database: \n # role: \n # authenticator: \n # token: \n # host: \n # port: \n # application: Tobiko_SQLMesh\n # private_key: \n # private_key_path: \n # private_key_passphrase: \n # session_parameters: ",
2272-
"databricks": "# concurrent_tasks: 1\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # server_hostname: \n # http_path: \n # access_token: \n # auth_type: \n # oauth_client_id: \n # oauth_client_secret: \n # catalog: \n # http_headers: \n # session_configuration: \n # databricks_connect_server_hostname: \n # databricks_connect_access_token: \n # databricks_connect_cluster_id: \n # databricks_connect_use_serverless: False\n # force_databricks_connect: False\n # disable_databricks_connect: False\n # disable_spark_session: False",
2272+
"databricks": "# concurrent_tasks: 1\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # server_hostname: \n # http_path: \n # access_token: \n # auth_type: \n # oauth_client_id: \n # oauth_client_secret: \n # catalog: \n # http_headers: \n # session_configuration: \n # databricks_connect_server_hostname: \n # databricks_connect_access_token: \n # databricks_connect_cluster_id: \n # databricks_connect_use_serverless: False\n # force_databricks_connect: False\n # disable_databricks_connect: False\n # disable_spark_session: False\n # force_shared_connection: False",
22732273
"postgres": "host: \n user: \n password: \n port: \n database: \n # concurrent_tasks: 4\n # register_comments: True\n # pre_ping: True\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # keepalives_idle: \n # connect_timeout: 10\n # role: \n # sslmode: \n # application_name: ",
22742274
}
22752275

0 commit comments

Comments
 (0)