Skip to content

Commit f5e8adb

Browse files
rahuls-dbIsaac
andcommitted
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>
1 parent 13e8af4 commit f5e8adb

8 files changed

Lines changed: 456 additions & 4 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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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)`` — the host is part of the key so the same
10+
warehouse id observed on two different workspaces never collides. Entries
11+
expire after ``_TTL_SECONDS`` so a warehouse later reconfigured to accept Thrift
12+
is eventually retried.
13+
"""
14+
15+
import re
16+
import threading
17+
import time
18+
from typing import Dict, Optional, Tuple
19+
20+
# A warehouse's Reyden membership can change (an id may be recreated on a
21+
# Thrift-capable endpoint), so cached entries are re-validated after this long.
22+
# Matches the ADBC driver's 6-hour horizon.
23+
_TTL_SECONDS = 6 * 60 * 60
24+
25+
# Warehouse paths look like ``/sql/1.0/warehouses/<id>`` or
26+
# ``.../endpoints/<id>``; the id stops at the next ``/``, ``?`` or ``&`` (e.g. a
27+
# ``?o=`` SPOG routing param). All-purpose-compute cluster paths carry no
28+
# warehouse id and never match — they are never Reyden warehouses.
29+
_WAREHOUSE_PATH_RE = re.compile(r".*/(?:warehouses|endpoints)/([^?&/]+)")
30+
31+
32+
def extract_warehouse_id(http_path: Optional[str]) -> Optional[str]:
33+
"""Return the warehouse/endpoint id embedded in ``http_path``, or ``None``."""
34+
if not http_path:
35+
return None
36+
match = _WAREHOUSE_PATH_RE.match(http_path)
37+
return match.group(1) if match else None
38+
39+
40+
class _ReydenWarehouseCache:
41+
def __init__(self, ttl_seconds: float = _TTL_SECONDS) -> None:
42+
self._ttl_seconds = ttl_seconds
43+
self._lock = threading.Lock()
44+
# (host_lowercased, warehouse_id) -> monotonic expiry deadline
45+
self._expiry: Dict[Tuple[str, str], float] = {}
46+
47+
@staticmethod
48+
def _key(host: str, warehouse_id: str) -> Tuple[str, str]:
49+
return (host.lower(), warehouse_id)
50+
51+
def mark_reyden(self, host: str, warehouse_id: str) -> None:
52+
now = time.monotonic()
53+
with self._lock:
54+
# Opportunistic sweep: mark_reyden only runs on an actual Thrift
55+
# rejection (rare), so purging every expired entry here is near-free
56+
# and bounds the cache to warehouses seen within the TTL window
57+
# rather than every warehouse ever seen (the per-key lazy eviction
58+
# in is_known_reyden never reclaims a warehouse that is not looked
59+
# up again).
60+
for key in [k for k, deadline in self._expiry.items() if deadline <= now]:
61+
del self._expiry[key]
62+
self._expiry[self._key(host, warehouse_id)] = now + self._ttl_seconds
63+
64+
def is_known_reyden(self, host: str, warehouse_id: str) -> bool:
65+
key = self._key(host, warehouse_id)
66+
now = time.monotonic()
67+
with self._lock:
68+
deadline = self._expiry.get(key)
69+
if deadline is None:
70+
return False
71+
if deadline <= now:
72+
# Lazily evict so a reconfigured warehouse is retried over Thrift.
73+
del self._expiry[key]
74+
return False
75+
return True
76+
77+
def clear(self) -> None:
78+
with self._lock:
79+
self._expiry.clear()
80+
81+
82+
# Process-wide singleton; multi-tenant safe via the host component of the key.
83+
_CACHE = _ReydenWarehouseCache()
84+
85+
86+
def mark_reyden(host: str, warehouse_id: str) -> None:
87+
"""Record that ``warehouse_id`` on ``host`` rejects the Thrift protocol."""
88+
_CACHE.mark_reyden(host, warehouse_id)
89+
90+
91+
def is_known_reyden(host: str, warehouse_id: str) -> bool:
92+
"""Whether ``warehouse_id`` on ``host`` is known (unexpired) to reject Thrift."""
93+
return _CACHE.is_known_reyden(host, warehouse_id)
94+
95+
96+
def clear_cache() -> None:
97+
"""Reset the cache. Intended for tests."""
98+
_CACHE.clear()

src/databricks/sql/backend/thrift_backend.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,13 @@ def _check_response_for_error(response, host_url=None):
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. Surface a distinct marker so the connection
294+
# layer can transparently re-open on the kernel backend. host_url is
295+
# deliberately omitted: this is a recoverable signal, not a terminal
296+
# failure, so it must not emit a failure-telemetry event here.
297+
if response.status.sqlState == ReydenThriftUnsupportedError.SQL_STATE:
298+
raise ReydenThriftUnsupportedError(response.status.errorMessage)
292299
raise DatabaseError(
293300
response.status.errorMessage,
294301
host_url=host_url,

src/databricks/sql/client.py

Lines changed: 87 additions & 4 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
@@ -399,18 +405,16 @@ 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.
416420
# For use_kernel connections the kernel owns telemetry, so suppress
@@ -512,6 +516,85 @@ def read(self) -> Optional[OAuthToken]:
512516
session_id=self.get_session_id_hex(),
513517
)
514518

519+
def _open_session_with_reyden_fallback(
520+
self,
521+
server_hostname: str,
522+
http_path: str,
523+
http_headers,
524+
session_configuration,
525+
catalog,
526+
schema,
527+
_use_arrow_native_complex_types,
528+
kwargs: dict,
529+
) -> Session:
530+
"""Open a ``Session``, transparently recovering onto the kernel backend
531+
when a Reyden / Real-Time warehouse rejects the default Thrift protocol.
532+
533+
Reyden warehouses reject a Thrift ``OpenSession`` (SQLSTATE ``KP001``);
534+
the kernel (SEA) backend is the supported path. Auto-recovery applies
535+
only when the caller did not pick a backend explicitly (neither
536+
``use_kernel`` nor ``use_sea``). On a rejection the warehouse is
537+
remembered so later connections skip the doomed Thrift attempt.
538+
"""
539+
540+
def build_session(session_kwargs: dict) -> Session:
541+
# Assign self.session before open() so a failed open still leaves the
542+
# attempted session on the connection — __del__ and the failure
543+
# telemetry log both rely on self.session being present.
544+
self.session = Session(
545+
server_hostname,
546+
http_path,
547+
self.http_client,
548+
http_headers,
549+
session_configuration,
550+
catalog,
551+
schema,
552+
_use_arrow_native_complex_types,
553+
**session_kwargs,
554+
)
555+
self.session.open()
556+
return self.session
557+
558+
# An explicit backend choice is always honored — auto-recovery engages
559+
# only on the default (Thrift) path.
560+
explicit_backend = kwargs.get("use_kernel", False) or kwargs.get(
561+
"use_sea", False
562+
)
563+
if explicit_backend:
564+
return build_session(kwargs)
565+
566+
warehouse_id = extract_warehouse_id(http_path)
567+
568+
# Pre-check: a warehouse already seen to reject Thrift opens straight on
569+
# the kernel, skipping the doomed Thrift OpenSession round-trip.
570+
if warehouse_id and is_known_reyden(server_hostname, warehouse_id):
571+
logger.info(
572+
"Warehouse %s on %s is known to require the kernel backend; "
573+
"opening on the kernel and skipping Thrift.",
574+
warehouse_id,
575+
server_hostname,
576+
)
577+
return build_session({**kwargs, "use_kernel": True})
578+
579+
try:
580+
return build_session(kwargs)
581+
except ReydenThriftUnsupportedError as thrift_ex:
582+
logger.info(
583+
"Thrift is not supported for this Reyden/Real-Time warehouse; "
584+
"transparently re-opening the session on the kernel backend."
585+
)
586+
# Remember the rejection regardless of the retry's outcome — the
587+
# warehouse is Reyden either way, so future connects should skip
588+
# Thrift; a kernel failure below is a separate, orthogonal problem.
589+
if warehouse_id:
590+
mark_reyden(server_hostname, warehouse_id)
591+
try:
592+
return build_session({**kwargs, "use_kernel": True})
593+
except Exception as kernel_ex:
594+
# Surface the kernel failure (the actionable one) while keeping
595+
# the original Thrift rejection in the chain for diagnosis.
596+
raise kernel_ex from thrift_ex
597+
515598
def _set_use_inline_params_with_warning(self, value: Union[bool, str]):
516599
"""Valid values are True, False, and "silent"
517600

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:
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import pytest
2+
3+
from databricks.sql.backend import reyden_warehouse_cache
4+
from databricks.sql.backend.reyden_warehouse_cache import (
5+
_ReydenWarehouseCache,
6+
extract_warehouse_id,
7+
)
8+
9+
10+
class TestExtractWarehouseId:
11+
@pytest.mark.parametrize(
12+
"path, expected",
13+
[
14+
("/sql/1.0/warehouses/abc123", "abc123"),
15+
("/sql/1.0/endpoints/def456", "def456"),
16+
("/sql/1.0/warehouses/abc123?o=42", "abc123"),
17+
("sql/1.0/warehouses/wh?param=1&o=2", "wh"),
18+
# All-purpose-compute cluster path — no warehouse id.
19+
("/sql/protocolv1/o/1234567890/0101-cluster", None),
20+
("", None),
21+
(None, None),
22+
],
23+
)
24+
def test_extract(self, path, expected):
25+
assert extract_warehouse_id(path) == expected
26+
27+
28+
class TestReydenWarehouseCacheClass:
29+
def test_mark_then_known(self):
30+
cache = _ReydenWarehouseCache()
31+
assert cache.is_known_reyden("host", "wh") is False
32+
cache.mark_reyden("host", "wh")
33+
assert cache.is_known_reyden("host", "wh") is True
34+
35+
def test_host_case_insensitive(self):
36+
cache = _ReydenWarehouseCache()
37+
cache.mark_reyden("Host.Example.COM", "wh")
38+
assert cache.is_known_reyden("host.example.com", "wh") is True
39+
40+
def test_distinct_hosts_do_not_collide(self):
41+
cache = _ReydenWarehouseCache()
42+
cache.mark_reyden("host-a", "wh")
43+
# Same warehouse id on a different host must not be treated as Reyden.
44+
assert cache.is_known_reyden("host-b", "wh") is False
45+
46+
def test_distinct_warehouses_do_not_collide(self):
47+
cache = _ReydenWarehouseCache()
48+
cache.mark_reyden("host", "wh-a")
49+
assert cache.is_known_reyden("host", "wh-b") is False
50+
51+
def test_entry_expires_and_is_evicted(self):
52+
cache = _ReydenWarehouseCache(ttl_seconds=0)
53+
cache.mark_reyden("host", "wh")
54+
# A zero TTL means the deadline is already in the past on read.
55+
assert cache.is_known_reyden("host", "wh") is False
56+
# Expired entry is evicted, not just reported absent.
57+
assert cache._expiry == {}
58+
59+
def test_mark_sweeps_expired_entries(self):
60+
# A zero TTL makes the first entry expired by the time the second mark
61+
# runs, so the opportunistic sweep must purge it even though it was
62+
# never read back.
63+
cache = _ReydenWarehouseCache(ttl_seconds=0)
64+
cache.mark_reyden("host", "old")
65+
assert ("host", "old") in cache._expiry
66+
cache.mark_reyden("host", "new")
67+
assert ("host", "old") not in cache._expiry
68+
assert ("host", "new") in cache._expiry
69+
70+
def test_mark_keeps_live_entries(self):
71+
# Live (unexpired) entries survive the sweep on a subsequent mark.
72+
cache = _ReydenWarehouseCache(ttl_seconds=3600)
73+
cache.mark_reyden("host", "a")
74+
cache.mark_reyden("host", "b")
75+
assert ("host", "a") in cache._expiry
76+
assert ("host", "b") in cache._expiry
77+
78+
79+
class TestModuleSingleton:
80+
def test_mark_and_clear(self):
81+
reyden_warehouse_cache.clear_cache()
82+
reyden_warehouse_cache.mark_reyden("host", "wh")
83+
assert reyden_warehouse_cache.is_known_reyden("host", "wh") is True
84+
reyden_warehouse_cache.clear_cache()
85+
assert reyden_warehouse_cache.is_known_reyden("host", "wh") is False

0 commit comments

Comments
 (0)