Skip to content

Commit 696e780

Browse files
rahuls-dbIsaac
andauthored
feat: auto-recover Reyden Thrift connections onto the kernel (#948)
* feat: auto-recover Reyden Thrift connections onto the kernel An unconfigured connect() to a Reyden / Real-Time SQL warehouse defaults to the Thrift backend, which the SQL Gateway proxy rejects with SQLSTATE KP001. Detect that rejection at OpenSession and transparently re-open the session on the kernel backend, and remember the warehouse (process-wide cache keyed by (host, warehouse_id), ~6h TTL) so subsequent connects skip the doomed Thrift attempt. Only the default path auto-recovers; an explicit use_kernel/use_sea is always honored. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * fix: attribute recovered-kernel connection failures to the kernel for telemetry The connection-failure telemetry suppression read the original connect() kwargs to decide whether the failed connection was a kernel connection. On the Reyden auto-recovery path the kernel retry uses a kwargs copy, so the original still said Thrift — a kernel open-failure was logged by the wrapper despite the kernel owning telemetry for kernel connections. Decide from the session that actually failed (self.session.use_kernel) instead. If the kernel was never constructed (e.g. its wheel is missing), self.session stays Thrift and the wrapper still logs, so that otherwise-invisible failure is still recorded. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * refactor: scope KP001 Reyden detection to OpenSession only _check_response_for_error runs for every Thrift RPC, so mapping KP001 to the recoverable marker there gave it a wider blast radius than the recovery logic (which only wraps session open): a stray KP001 on any other RPC would have surfaced as ReydenThriftUnsupportedError with no handler. Gate the mapping behind a detect_reyden flag that make_request sets only for the OpenSession method (mirroring the existing method.__name__ discrimination). Every other RPC now surfaces a KP001 as a generic DatabaseError, unchanged from before. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * Fix telemetry test mocks to set session.use_kernel The connection-failure telemetry suppression reads session.use_kernel, but these tests mock Session, so use_kernel was a truthy MagicMock — which suppressed the wrapper failure log and broke test_connection_failure_sends_correct_telemetry_payload. Set use_kernel explicitly on the mock (False for the default Thrift case, True for the kernel case) to reflect production. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * Mirror the Thrift auth default onto the Reyden kernel recovery path On auto-recovery the Thrift default of auth_type=None (implicitly databricks-oauth) was forwarded unchanged to the kernel, which has no such fallback and rejects auth_type=None unless a PAT/M2M credential shape is present — so a bare OAuth-U2M connection failed to recover. Inject auth_type=databricks-oauth on the default-path kernel retry when auth_type is unset and no credential shape is present, mirroring Thrift; skip it when a credential shape exists so kernel routing is unchanged. Also correct the warehouse-cache docstring: warehouse ids are globally unique, so there is no cross-workspace collision on a shared SPOG host; the host component is an optimization, not a correctness requirement. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> --------- Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
1 parent 13e8af4 commit 696e780

9 files changed

Lines changed: 606 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Release History
22

3+
# Unreleased
4+
- Transparently auto-recover Thrift connections to Reyden / Real-Time warehouses: when a warehouse rejects the default Thrift protocol (SQLSTATE `KP001`), the session is re-opened on the kernel backend and the warehouse is remembered so later connections skip Thrift. Applies only when no backend was chosen explicitly.
5+
36
# 4.5.0 (2026-09-01)
47
- Upgrade Databricks SQL Kernel to 1.0.0.
58
- Add JWT private-key M2M and Azure Entra authentication for kernel connections.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Process-wide cache of warehouses known to reject the legacy Thrift protocol.
2+
3+
A Reyden / Real-Time SQL warehouse rejects a Thrift ``OpenSession`` — the SQL
4+
Gateway proxy stamps SQLSTATE ``KP001`` on the rejection. When the driver
5+
auto-recovers by re-opening on the kernel backend, it records the warehouse
6+
here so later connections to the same warehouse skip the doomed Thrift attempt
7+
and open on the kernel directly.
8+
9+
Keyed by ``(host, warehouse_id)``. Warehouse ids are globally unique, so the
10+
warehouse id alone identifies the warehouse — even on a SPOG host shared by many
11+
workspaces (where only the ``?o=<workspace-id>`` path param distinguishes them),
12+
there is no cross-workspace collision. The host is kept in the key only as a
13+
cheap optimization (scoping lookups) and defense-in-depth, not for correctness.
14+
Entries expire after ``_TTL_SECONDS`` so a warehouse later reconfigured to accept
15+
Thrift is eventually retried.
16+
"""
17+
18+
import re
19+
import threading
20+
import time
21+
from typing import Dict, Optional, Tuple
22+
23+
# A warehouse's Reyden membership can change (an id may be recreated on a
24+
# Thrift-capable endpoint), so cached entries are re-validated after this long.
25+
# Matches the ADBC driver's 6-hour horizon.
26+
_TTL_SECONDS = 6 * 60 * 60
27+
28+
# Warehouse paths look like ``/sql/1.0/warehouses/<id>`` or
29+
# ``.../endpoints/<id>``; the id stops at the next ``/``, ``?`` or ``&`` (e.g. a
30+
# ``?o=`` SPOG routing param). All-purpose-compute cluster paths carry no
31+
# warehouse id and never match — they are never Reyden warehouses.
32+
_WAREHOUSE_PATH_RE = re.compile(r".*/(?:warehouses|endpoints)/([^?&/]+)")
33+
34+
35+
def extract_warehouse_id(http_path: Optional[str]) -> Optional[str]:
36+
"""Return the warehouse/endpoint id embedded in ``http_path``, or ``None``."""
37+
if not http_path:
38+
return None
39+
match = _WAREHOUSE_PATH_RE.match(http_path)
40+
return match.group(1) if match else None
41+
42+
43+
class _ReydenWarehouseCache:
44+
def __init__(self, ttl_seconds: float = _TTL_SECONDS) -> None:
45+
self._ttl_seconds = ttl_seconds
46+
self._lock = threading.Lock()
47+
# (host_lowercased, warehouse_id) -> monotonic expiry deadline
48+
self._expiry: Dict[Tuple[str, str], float] = {}
49+
50+
@staticmethod
51+
def _key(host: str, warehouse_id: str) -> Tuple[str, str]:
52+
return (host.lower(), warehouse_id)
53+
54+
def mark_reyden(self, host: str, warehouse_id: str) -> None:
55+
now = time.monotonic()
56+
with self._lock:
57+
# Opportunistic sweep: mark_reyden only runs on an actual Thrift
58+
# rejection (rare), so purging every expired entry here is near-free
59+
# and bounds the cache to warehouses seen within the TTL window
60+
# rather than every warehouse ever seen (the per-key lazy eviction
61+
# in is_known_reyden never reclaims a warehouse that is not looked
62+
# up again).
63+
for key in [k for k, deadline in self._expiry.items() if deadline <= now]:
64+
del self._expiry[key]
65+
self._expiry[self._key(host, warehouse_id)] = now + self._ttl_seconds
66+
67+
def is_known_reyden(self, host: str, warehouse_id: str) -> bool:
68+
key = self._key(host, warehouse_id)
69+
now = time.monotonic()
70+
with self._lock:
71+
deadline = self._expiry.get(key)
72+
if deadline is None:
73+
return False
74+
if deadline <= now:
75+
# Lazily evict so a reconfigured warehouse is retried over Thrift.
76+
del self._expiry[key]
77+
return False
78+
return True
79+
80+
def clear(self) -> None:
81+
with self._lock:
82+
self._expiry.clear()
83+
84+
85+
# Process-wide singleton; multi-tenant safe via the host component of the key.
86+
_CACHE = _ReydenWarehouseCache()
87+
88+
89+
def mark_reyden(host: str, warehouse_id: str) -> None:
90+
"""Record that ``warehouse_id`` on ``host`` rejects the Thrift protocol."""
91+
_CACHE.mark_reyden(host, warehouse_id)
92+
93+
94+
def is_known_reyden(host: str, warehouse_id: str) -> bool:
95+
"""Whether ``warehouse_id`` on ``host`` is known (unexpired) to reject Thrift."""
96+
return _CACHE.is_known_reyden(host, warehouse_id)
97+
98+
99+
def clear_cache() -> None:
100+
"""Reset the cache. Intended for tests."""
101+
_CACHE.clear()

src/databricks/sql/backend/thrift_backend.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,23 @@ def _initialize_retry_args(self, kwargs):
284284
)
285285

286286
@staticmethod
287-
def _check_response_for_error(response, host_url=None):
287+
def _check_response_for_error(response, host_url=None, detect_reyden=False):
288288
if response.status and response.status.statusCode in [
289289
ttypes.TStatusCode.ERROR_STATUS,
290290
ttypes.TStatusCode.INVALID_HANDLE_STATUS,
291291
]:
292+
# A Reyden / Real-Time warehouse rejects the legacy Thrift protocol
293+
# with SQLSTATE KP001, but only at OpenSession. `detect_reyden` gates
294+
# the marker to that call so a stray KP001 on any other RPC surfaces
295+
# as a normal DatabaseError (the connection-layer recovery only wraps
296+
# session open). host_url is deliberately omitted on the marker: it is
297+
# a recoverable signal, not a terminal failure, so it must not emit a
298+
# failure-telemetry event here.
299+
if (
300+
detect_reyden
301+
and response.status.sqlState == ReydenThriftUnsupportedError.SQL_STATE
302+
):
303+
raise ReydenThriftUnsupportedError(response.status.errorMessage)
292304
raise DatabaseError(
293305
response.status.errorMessage,
294306
host_url=host_url,
@@ -520,7 +532,14 @@ def attempt_request(attempt):
520532
if not isinstance(response_or_error_info, RequestErrorInfo):
521533
# log nothing here, presume that main request logging covers
522534
response = response_or_error_info
523-
ThriftDatabricksClient._check_response_for_error(response, self._host)
535+
# Only OpenSession opts into KP001→Reyden-marker detection (the
536+
# rejection is stamped only there). Mirrors the method.__name__
537+
# discrimination already used above for GetOperationStatus.
538+
ThriftDatabricksClient._check_response_for_error(
539+
response,
540+
self._host,
541+
detect_reyden=getattr(method, "__name__", None) == "OpenSession",
542+
)
524543
return response
525544

526545
error_info = response_or_error_info

src/databricks/sql/client.py

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
ProgrammingError,
3636
TransactionError,
3737
DatabaseError,
38+
ReydenThriftUnsupportedError,
39+
)
40+
from databricks.sql.backend.reyden_warehouse_cache import (
41+
extract_warehouse_id,
42+
is_known_reyden,
43+
mark_reyden,
3844
)
3945

4046
from databricks.sql.backend.databricks_client import DatabricksClient
@@ -66,7 +72,7 @@
6672
from databricks.sql.session import Session
6773
from databricks.sql.backend.types import CommandId, BackendType, CommandState, SessionId
6874

69-
from databricks.sql.auth.common import ClientContext
75+
from databricks.sql.auth.common import AuthType, ClientContext
7076
from databricks.sql.common.unified_http_client import UnifiedHttpClient
7177
from databricks.sql.common.http import HttpMethod
7278

@@ -399,24 +405,30 @@ def read(self) -> Optional[OAuthToken]:
399405
self.http_client = UnifiedHttpClient(client_context)
400406

401407
try:
402-
self.session = Session(
408+
self.session = self._open_session_with_reyden_fallback(
403409
server_hostname,
404410
http_path,
405-
self.http_client,
406411
http_headers,
407412
session_configuration,
408413
catalog,
409414
schema,
410415
_use_arrow_native_complex_types,
411-
**kwargs,
416+
kwargs,
412417
)
413-
self.session.open()
414418
except Exception as e:
415419
# Respect user's telemetry preference even during connection failure.
416-
# For use_kernel connections the kernel owns telemetry, so suppress
417-
# the wrapper-side failure log to avoid wrapper-vs-kernel duplication.
418-
enable_telemetry = kwargs.get("enable_telemetry", True) and not kwargs.get(
419-
"use_kernel", False
420+
# For a kernel connection the kernel owns telemetry, so suppress the
421+
# wrapper-side failure log to avoid wrapper-vs-kernel duplication.
422+
# Read the backend from the session that actually failed rather than
423+
# the caller's kwargs: on the Reyden auto-recovery path we retry on
424+
# the kernel via a kwargs copy, so the original kwargs still says
425+
# Thrift. If the kernel never got constructed (e.g. its wheel is
426+
# missing), self.session is the Thrift session and we still log.
427+
attempted_kernel = getattr(
428+
getattr(self, "session", None), "use_kernel", False
429+
)
430+
enable_telemetry = (
431+
kwargs.get("enable_telemetry", True) and not attempted_kernel
420432
)
421433
TelemetryClientFactory.connection_failure_log(
422434
error_name="Exception",
@@ -512,6 +524,106 @@ def read(self) -> Optional[OAuthToken]:
512524
session_id=self.get_session_id_hex(),
513525
)
514526

527+
def _open_session_with_reyden_fallback(
528+
self,
529+
server_hostname: str,
530+
http_path: str,
531+
http_headers,
532+
session_configuration,
533+
catalog,
534+
schema,
535+
_use_arrow_native_complex_types,
536+
kwargs: dict,
537+
) -> Session:
538+
"""Open a ``Session``, transparently recovering onto the kernel backend
539+
when a Reyden / Real-Time warehouse rejects the default Thrift protocol.
540+
541+
Reyden warehouses reject a Thrift ``OpenSession`` (SQLSTATE ``KP001``);
542+
the kernel (SEA) backend is the supported path. Auto-recovery applies
543+
only when the caller did not pick a backend explicitly (neither
544+
``use_kernel`` nor ``use_sea``). On a rejection the warehouse is
545+
remembered so later connections skip the doomed Thrift attempt.
546+
"""
547+
548+
def build_session(session_kwargs: dict) -> Session:
549+
# Assign self.session before open() so a failed open still leaves the
550+
# attempted session on the connection — __del__ and the failure
551+
# telemetry log both rely on self.session being present.
552+
self.session = Session(
553+
server_hostname,
554+
http_path,
555+
self.http_client,
556+
http_headers,
557+
session_configuration,
558+
catalog,
559+
schema,
560+
_use_arrow_native_complex_types,
561+
**session_kwargs,
562+
)
563+
self.session.open()
564+
return self.session
565+
566+
def kernel_recovery_kwargs() -> dict:
567+
# Kwargs for re-opening on the kernel. The Thrift path treats an
568+
# unset auth_type as databricks-oauth (see get_auth_provider); the
569+
# kernel path has no such fallback and rejects auth_type=None unless
570+
# a credential shape (PAT / OAuth M2M) is present. Mirror the Thrift
571+
# default so a bare OAuth-U2M connection recovers instead of failing
572+
# with NotSupportedError. Skip the injection when a credential shape
573+
# is already present — the kernel routes on it regardless of
574+
# auth_type, and forcing databricks-oauth alongside an M2M secret or
575+
# a credentials_provider would change that routing.
576+
recovery_kwargs = {**kwargs, "use_kernel": True}
577+
has_credential_shape = (
578+
recovery_kwargs.get("access_token")
579+
or recovery_kwargs.get("oauth_client_secret")
580+
or recovery_kwargs.get("oauth_jwt_key_file")
581+
or recovery_kwargs.get("credentials_provider")
582+
)
583+
if recovery_kwargs.get("auth_type") is None and not has_credential_shape:
584+
recovery_kwargs["auth_type"] = AuthType.DATABRICKS_OAUTH.value
585+
return recovery_kwargs
586+
587+
# An explicit backend choice is always honored — auto-recovery engages
588+
# only on the default (Thrift) path.
589+
explicit_backend = kwargs.get("use_kernel", False) or kwargs.get(
590+
"use_sea", False
591+
)
592+
if explicit_backend:
593+
return build_session(kwargs)
594+
595+
warehouse_id = extract_warehouse_id(http_path)
596+
597+
# Pre-check: a warehouse already seen to reject Thrift opens straight on
598+
# the kernel, skipping the doomed Thrift OpenSession round-trip.
599+
if warehouse_id and is_known_reyden(server_hostname, warehouse_id):
600+
logger.info(
601+
"Warehouse %s on %s is known to require the kernel backend; "
602+
"opening on the kernel and skipping Thrift.",
603+
warehouse_id,
604+
server_hostname,
605+
)
606+
return build_session(kernel_recovery_kwargs())
607+
608+
try:
609+
return build_session(kwargs)
610+
except ReydenThriftUnsupportedError as thrift_ex:
611+
logger.info(
612+
"Thrift is not supported for this Reyden/Real-Time warehouse; "
613+
"transparently re-opening the session on the kernel backend."
614+
)
615+
# Remember the rejection regardless of the retry's outcome — the
616+
# warehouse is Reyden either way, so future connects should skip
617+
# Thrift; a kernel failure below is a separate, orthogonal problem.
618+
if warehouse_id:
619+
mark_reyden(server_hostname, warehouse_id)
620+
try:
621+
return build_session(kernel_recovery_kwargs())
622+
except Exception as kernel_ex:
623+
# Surface the kernel failure (the actionable one) while keeping
624+
# the original Thrift rejection in the chain for diagnosis.
625+
raise kernel_ex from thrift_ex
626+
515627
def _set_use_inline_params_with_warning(self, value: Union[bool, str]):
516628
"""Valid values are True, False, and "silent"
517629

src/databricks/sql/exc.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,20 @@ class ServerOperationError(DatabaseError):
114114
pass
115115

116116

117+
class ReydenThriftUnsupportedError(DatabaseError):
118+
"""Marker for a Reyden / Real-Time warehouse rejecting the legacy Thrift
119+
protocol at OpenSession (the SQL Gateway proxy stamps SQLSTATE ``KP001``).
120+
121+
It signals the connection layer to transparently re-open the session on the
122+
kernel backend. Subclassing ``DatabaseError`` means that when auto-recovery
123+
does not apply (an explicit backend was chosen) or the kernel retry also
124+
fails, callers catching ``DatabaseError`` still observe it.
125+
"""
126+
127+
# SQLSTATE the SQL Gateway proxy stamps on the Reyden Thrift rejection.
128+
SQL_STATE = "KP001"
129+
130+
117131
class RequestError(OperationalError):
118132
"""Thrown if there was a error during request to the server.
119133
Its context will have the following keys:

0 commit comments

Comments
 (0)