Skip to content

Commit 9c318f4

Browse files
rahuls-dbIsaac
andcommitted
feat: auto-recover Reyden Thrift connections onto the kernel backend
An unconfigured connection to a Reyden / Real-Time SQL warehouse defaults to the Thrift backend, which the SQL Gateway proxy rejects with SQLSTATE KP001. Detect that rejection (StatusError.sqlState === "KP001") in ThriftBackend.openSession and transparently re-open the session on the KernelBackend (SEA), remembering the warehouse in a process-wide cache keyed by (host, warehouse_id) with a ~6h TTL so later connects skip the doomed Thrift attempt. Only the default path auto-recovers; an explicit backend choice (routed upstream in the client) is unaffected. On a double failure the kernel error is surfaced with the original Thrift rejection preserved as its cause. Also re-throw the original error unchanged on the non-recovery paths: StatusError implements Error but does not extend it, so the previous `error instanceof Error ? error : new Error(String(error))` normalization wrapped every StatusError into Error("[object Object]"), losing its sqlState and the double-failure cause. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>
1 parent 2406f31 commit 9c318f4

5 files changed

Lines changed: 467 additions & 2 deletions

File tree

lib/ReydenWarehouseCache.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Process-wide cache for tracking Reyden (Real-Time SQL) warehouses.
3+
*
4+
* When a Thrift OpenSession fails with SQLSTATE KP001, the driver falls back
5+
* to the SEA (Statement Execution API) backend. This cache avoids retrying
6+
* the same failed Thrift path on subsequent connections by recording which
7+
* warehouses are known to require SEA.
8+
*
9+
* The cache is keyed by (host_lowercased, warehouse_id) to handle multi-tenant
10+
* safety — the same warehouse ID on different hosts may have different support.
11+
*
12+
* TTL is ~6 hours to allow the server side to update warehouse routing without
13+
* requiring a process restart. Expired entries are opportunistically evicted on
14+
* access (no background GC thread — Node is single-threaded).
15+
*/
16+
17+
const TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
18+
19+
interface CacheEntry {
20+
timestamp: number;
21+
isReyden: boolean;
22+
}
23+
24+
class ReydenWarehouseCache {
25+
private static instance?: ReydenWarehouseCache;
26+
27+
private cache: Map<string, CacheEntry> = new Map();
28+
29+
// Singleton: constructor is private to enforce getInstance() usage
30+
// eslint-disable-next-line @typescript-eslint/no-empty-function
31+
private constructor() {}
32+
33+
public static getInstance(): ReydenWarehouseCache {
34+
if (!ReydenWarehouseCache.instance) {
35+
ReydenWarehouseCache.instance = new ReydenWarehouseCache();
36+
}
37+
return ReydenWarehouseCache.instance;
38+
}
39+
40+
/**
41+
* Constructs a cache key from host and warehouse ID.
42+
* Host is lowercased for case-insensitive comparison.
43+
*/
44+
private getKey(host: string, warehouseId: string): string {
45+
return `${host.toLowerCase()}:${warehouseId}`;
46+
}
47+
48+
/**
49+
* Check if an entry is expired based on TTL.
50+
*/
51+
private isExpired(entry: CacheEntry): boolean {
52+
return Date.now() - entry.timestamp > TTL_MS;
53+
}
54+
55+
/**
56+
* Checks if a warehouse is known to be Reyden (requiring SEA fallback).
57+
* Returns undefined if the warehouse is not in the cache or the entry has expired.
58+
*/
59+
public isKnownReyden(host: string, warehouseId: string): boolean | undefined {
60+
const key = this.getKey(host, warehouseId);
61+
const entry = this.cache.get(key);
62+
63+
if (!entry) {
64+
return undefined;
65+
}
66+
67+
// Opportunistically evict expired entries on access
68+
if (this.isExpired(entry)) {
69+
this.cache.delete(key);
70+
return undefined;
71+
}
72+
73+
return entry.isReyden;
74+
}
75+
76+
/**
77+
* Mark a warehouse as being Reyden (KP001 rejection detected).
78+
*/
79+
public markReyden(host: string, warehouseId: string): void {
80+
const key = this.getKey(host, warehouseId);
81+
this.cache.set(key, {
82+
timestamp: Date.now(),
83+
isReyden: true,
84+
});
85+
}
86+
87+
/**
88+
* Clears the cache. Intended for testing only.
89+
*
90+
* @internal
91+
*/
92+
public clear(): void {
93+
this.cache.clear();
94+
}
95+
96+
/**
97+
* Returns the current cache size. Intended for testing/observability.
98+
*
99+
* @internal
100+
*/
101+
public size(): number {
102+
return this.cache.size;
103+
}
104+
}
105+
106+
export default ReydenWarehouseCache.getInstance();

lib/errors/StatusError.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ export default class StatusError implements Error {
77

88
public code: number;
99

10+
public sqlState?: string;
11+
1012
public stack?: string;
1113

1214
constructor(status: TStatus) {
1315
this.name = 'Status Error';
1416
this.message = status.errorMessage || '';
1517
this.code = status.errorCode || -1;
18+
this.sqlState = status.sqlState;
1619

1720
if (Array.isArray(status.infoMessages)) {
1821
this.stack = status.infoMessages.join('\n');

lib/thrift-backend/ThriftBackend.ts

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import Int64 from 'node-int64';
22
import IBackend from '../contracts/IBackend';
33
import ISessionBackend from '../contracts/ISessionBackend';
44
import IClientContext from '../contracts/IClientContext';
5-
import { OpenSessionRequest } from '../contracts/IDBSQLClient';
5+
import { ConnectionOptions, OpenSessionRequest } from '../contracts/IDBSQLClient';
66
import { TProtocolVersion } from '../../thrift/TCLIService_types';
77
import Status from '../dto/Status';
88
import { definedOrError, serializeQueryTags } from '../utils';
99
import ThriftSessionBackend from './ThriftSessionBackend';
10+
import StatusError from '../errors/StatusError';
11+
import reydenCache from '../ReydenWarehouseCache';
12+
import KernelBackend from '../kernel/KernelBackend';
13+
import { LogLevel } from '../contracts/IDBSQLLogger';
1014

1115
function getInitialNamespaceOptions(catalogName?: string, schemaName?: string) {
1216
if (!catalogName && !schemaName) {
@@ -31,12 +35,36 @@ export default class ThriftBackend implements IBackend {
3135

3236
private readonly onConnectionEvent: ThriftBackendOptions['onConnectionEvent'];
3337

38+
private connectionOptions?: ConnectionOptions;
39+
3440
constructor({ context, onConnectionEvent }: ThriftBackendOptions) {
3541
this.context = context;
3642
this.onConnectionEvent = onConnectionEvent;
3743
}
3844

39-
public async connect(): Promise<void> {
45+
/**
46+
* Extracts warehouse/endpoint ID from the HTTP path.
47+
* Matches patterns like `/sql/1.0/warehouses/<id>` or `/sql/1.0/endpoints/<id>`.
48+
* Returns undefined if no ID can be extracted.
49+
*/
50+
private static extractWarehouseId(httpPath: string | undefined): string | undefined {
51+
if (!httpPath) {
52+
return undefined;
53+
}
54+
55+
// Stop at query string
56+
const pathOnly = httpPath.split('?')[0];
57+
58+
// Match `/warehouses/<id>` or `/endpoints/<id>`
59+
// Stop at `/` or end of string
60+
const match = pathOnly.match(/\/(warehouses|endpoints)\/([^/]+)/);
61+
return match ? match[2] : undefined;
62+
}
63+
64+
public async connect(options: ConnectionOptions): Promise<void> {
65+
// Store connection options for warehouse ID extraction in openSession
66+
this.connectionOptions = options;
67+
4068
// The connection provider is owned by DBSQLClient (it implements IClientContext).
4169
// We only need to wire the EventEmitter listeners through this backend.
4270
const connectionProvider = await this.context.getConnectionProvider();
@@ -60,6 +88,57 @@ export default class ThriftBackend implements IBackend {
6088
}
6189

6290
public async openSession(request: OpenSessionRequest): Promise<ISessionBackend> {
91+
const logger = this.context.getLogger();
92+
93+
// Extract warehouse ID for cache lookups
94+
const warehouseId = ThriftBackend.extractWarehouseId(this.connectionOptions?.path);
95+
const host = this.connectionOptions?.host;
96+
97+
// Check if this warehouse is known to be Reyden (requires SEA backend)
98+
if (host && warehouseId && reydenCache.isKnownReyden(host, warehouseId)) {
99+
logger.log(LogLevel.debug, `Reyden: warehouse ${warehouseId} is known to require SEA fallback; skipping Thrift`);
100+
return this.openSessionWithKernelBackend(request);
101+
}
102+
103+
// Try Thrift first (default path).
104+
try {
105+
return await this.openSessionWithThrift(request);
106+
} catch (error) {
107+
// Only a Reyden KP001 rejection triggers fallback. Every other error
108+
// propagates unchanged — note StatusError is NOT an Error subclass
109+
// (it only `implements Error`), so it must be re-thrown as-is rather
110+
// than normalized, or its sqlState/message would be lost.
111+
if (error instanceof StatusError && error.sqlState === 'KP001') {
112+
logger.log(LogLevel.debug, `Reyden: detected KP001 on warehouse ${warehouseId}; falling back to SEA backend`);
113+
114+
// Mark this warehouse as Reyden for future connections.
115+
if (host && warehouseId) {
116+
reydenCache.markReyden(host, warehouseId);
117+
}
118+
119+
// Fall back to the kernel (SEA) backend exactly once. If it also fails,
120+
// surface the kernel error but keep the original Thrift rejection as its
121+
// cause for diagnosis.
122+
try {
123+
return await this.openSessionWithKernelBackend(request);
124+
} catch (kernelError) {
125+
if (kernelError && typeof kernelError === 'object') {
126+
(kernelError as { cause?: unknown }).cause = error;
127+
}
128+
logger.log(LogLevel.error, 'Reyden: both Thrift (KP001) and SEA fallback failed');
129+
throw kernelError;
130+
}
131+
}
132+
133+
// Not a Reyden rejection — surface the original error unchanged.
134+
throw error;
135+
}
136+
}
137+
138+
/**
139+
* Opens a session using the Thrift backend.
140+
*/
141+
private async openSessionWithThrift(request: OpenSessionRequest): Promise<ISessionBackend> {
63142
const driver = await this.context.getDriver();
64143
const config = this.context.getConfig();
65144

@@ -93,6 +172,24 @@ export default class ThriftBackend implements IBackend {
93172
});
94173
}
95174

175+
/**
176+
* Opens a session using the KernelBackend (SEA).
177+
* Called as a fallback when Thrift returns KP001 (Reyden rejection).
178+
*/
179+
private async openSessionWithKernelBackend(request: OpenSessionRequest): Promise<ISessionBackend> {
180+
if (!this.connectionOptions) {
181+
throw new Error('KernelBackend fallback: connection options not available');
182+
}
183+
184+
const logger = this.context.getLogger();
185+
logger.log(LogLevel.debug, 'Reyden: opening session via KernelBackend (SEA)');
186+
187+
// Create a new KernelBackend instance and connect/open
188+
const kernelBackend = new KernelBackend({ context: this.context });
189+
await kernelBackend.connect(this.connectionOptions);
190+
return kernelBackend.openSession(request);
191+
}
192+
96193
public async close(): Promise<void> {
97194
// DBSQLClient owns the connection lifecycle and clears its own state
98195
// (connectionProvider, authProvider, thrift client) after this returns.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { expect } from 'chai';
2+
import reydenCache from '../../../lib/ReydenWarehouseCache';
3+
import ThriftBackend from '../../../lib/thrift-backend/ThriftBackend';
4+
import StatusError from '../../../lib/errors/StatusError';
5+
import { TStatusCode } from '../../../thrift/TCLIService_types';
6+
7+
describe('Reyden Warehouse Cache', () => {
8+
beforeEach(() => {
9+
reydenCache.clear();
10+
});
11+
12+
afterEach(() => {
13+
reydenCache.clear();
14+
});
15+
16+
describe('Warehouse ID Extraction', () => {
17+
it('should extract warehouse ID from /warehouses/<id> path', () => {
18+
const extractWarehouseId = (ThriftBackend as any).extractWarehouseId;
19+
expect(extractWarehouseId('/sql/1.0/warehouses/abc123')).to.equal('abc123');
20+
});
21+
22+
it('should extract endpoint ID from /endpoints/<id> path', () => {
23+
const extractWarehouseId = (ThriftBackend as any).extractWarehouseId;
24+
expect(extractWarehouseId('/sql/1.0/endpoints/xyz789')).to.equal('xyz789');
25+
});
26+
27+
it('should stop at query string when extracting warehouse ID', () => {
28+
const extractWarehouseId = (ThriftBackend as any).extractWarehouseId;
29+
expect(extractWarehouseId('/sql/1.0/warehouses/abc123?o=12345')).to.equal('abc123');
30+
});
31+
32+
it('should return undefined if no warehouse ID is found', () => {
33+
const extractWarehouseId = (ThriftBackend as any).extractWarehouseId;
34+
expect(extractWarehouseId('/some/other/path')).to.be.undefined;
35+
});
36+
37+
it('should return undefined for undefined path', () => {
38+
const extractWarehouseId = (ThriftBackend as any).extractWarehouseId;
39+
expect(extractWarehouseId(undefined)).to.be.undefined;
40+
});
41+
});
42+
43+
describe('Cache Operations', () => {
44+
it('should mark a warehouse as Reyden', () => {
45+
const host = 'example.com';
46+
const warehouseId = 'warehouse-123';
47+
48+
expect(reydenCache.isKnownReyden(host, warehouseId)).to.be.undefined;
49+
reydenCache.markReyden(host, warehouseId);
50+
expect(reydenCache.isKnownReyden(host, warehouseId)).to.be.true;
51+
});
52+
53+
it('should be case-insensitive on host', () => {
54+
const warehouseId = 'warehouse-123';
55+
56+
reydenCache.markReyden('Example.COM', warehouseId);
57+
58+
expect(reydenCache.isKnownReyden('example.com', warehouseId)).to.be.true;
59+
expect(reydenCache.isKnownReyden('EXAMPLE.COM', warehouseId)).to.be.true;
60+
});
61+
62+
it('should isolate entries by warehouse ID', () => {
63+
const host = 'example.com';
64+
65+
reydenCache.markReyden(host, 'warehouse-1');
66+
67+
expect(reydenCache.isKnownReyden(host, 'warehouse-1')).to.be.true;
68+
expect(reydenCache.isKnownReyden(host, 'warehouse-2')).to.be.undefined;
69+
});
70+
71+
it('should isolate entries by host', () => {
72+
const warehouseId = 'warehouse-123';
73+
74+
reydenCache.markReyden('host1.com', warehouseId);
75+
76+
expect(reydenCache.isKnownReyden('host1.com', warehouseId)).to.be.true;
77+
expect(reydenCache.isKnownReyden('host2.com', warehouseId)).to.be.undefined;
78+
});
79+
80+
it('should have cache size method', () => {
81+
expect(reydenCache.size()).to.equal(0);
82+
83+
reydenCache.markReyden('host1.com', 'warehouse-1');
84+
expect(reydenCache.size()).to.equal(1);
85+
86+
reydenCache.markReyden('host1.com', 'warehouse-2');
87+
expect(reydenCache.size()).to.equal(2);
88+
});
89+
90+
it('should clear cache', () => {
91+
reydenCache.markReyden('host1.com', 'warehouse-1');
92+
reydenCache.markReyden('host2.com', 'warehouse-2');
93+
expect(reydenCache.size()).to.equal(2);
94+
95+
reydenCache.clear();
96+
expect(reydenCache.size()).to.equal(0);
97+
expect(reydenCache.isKnownReyden('host1.com', 'warehouse-1')).to.be.undefined;
98+
});
99+
});
100+
});
101+
102+
describe('StatusError SQLSTATE Support', () => {
103+
it('should capture SQLSTATE in StatusError', () => {
104+
const error = new StatusError({
105+
statusCode: TStatusCode.ERROR_STATUS,
106+
errorMessage: 'Some error',
107+
sqlState: 'KP001',
108+
});
109+
110+
expect(error.sqlState).to.equal('KP001');
111+
});
112+
113+
it('should handle undefined SQLSTATE', () => {
114+
const error = new StatusError({
115+
statusCode: TStatusCode.ERROR_STATUS,
116+
errorMessage: 'Some error',
117+
});
118+
119+
expect(error.sqlState).to.be.undefined;
120+
});
121+
122+
it('should detect KP001 errors correctly', () => {
123+
const kp001Error = new StatusError({
124+
statusCode: TStatusCode.ERROR_STATUS,
125+
errorMessage: 'Lakehouse/RT is not supported for Thrift protocol',
126+
sqlState: 'KP001',
127+
});
128+
129+
expect(kp001Error.sqlState).to.equal('KP001');
130+
});
131+
});

0 commit comments

Comments
 (0)