@lightdash/warehouses 2.129.0 → 2.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/warehouseClients/DatabricksWarehouseClient.d.ts +3 -1
- package/dist/warehouseClients/DatabricksWarehouseClient.d.ts.map +1 -1
- package/dist/warehouseClients/DatabricksWarehouseClient.js +109 -57
- package/dist/warehouseClients/DatabricksWarehouseClient.test.js +189 -14
- package/dist/warehouseClients/DatabricksWarehouseStartupRetry.d.ts +18 -0
- package/dist/warehouseClients/DatabricksWarehouseStartupRetry.d.ts.map +1 -0
- package/dist/warehouseClients/DatabricksWarehouseStartupRetry.js +65 -0
- package/dist/warehouseClients/DatabricksWarehouseStartupRetry.test.d.ts +2 -0
- package/dist/warehouseClients/DatabricksWarehouseStartupRetry.test.d.ts.map +1 -0
- package/dist/warehouseClients/DatabricksWarehouseStartupRetry.test.js +129 -0
- package/package.json +2 -2
|
@@ -31,12 +31,14 @@ export declare class DatabricksWarehouseClient extends WarehouseBaseClient<Creat
|
|
|
31
31
|
connectionOptions: ConnectionOptions;
|
|
32
32
|
private readonly enableTimeouts;
|
|
33
33
|
constructor(credentials: CreateDatabricksCredentials);
|
|
34
|
-
private
|
|
34
|
+
private openSession;
|
|
35
|
+
private streamQueryOnSession;
|
|
35
36
|
streamQuery(sql: string, streamCallback: (data: WarehouseResults) => void | Promise<void>, options: {
|
|
36
37
|
values?: AnyType[];
|
|
37
38
|
tags?: Record<string, string>;
|
|
38
39
|
timezone?: string;
|
|
39
40
|
}): Promise<void>;
|
|
41
|
+
private static getTableColumns;
|
|
40
42
|
getCatalog(requests: {
|
|
41
43
|
database: string;
|
|
42
44
|
schema: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DatabricksWarehouseClient.d.ts","sourceRoot":"","sources":["../../src/warehouseClients/DatabricksWarehouseClient.ts"],"names":[],"mappings":"AACA,OAAqB,EACjB,iBAAiB,EACpB,MAAM,6CAA6C,CAAC;AAKrD,OAAO,EACH,OAAO,EACP,2BAA2B,EAI3B,MAAM,EAIN,mBAAmB,EACnB,gBAAgB,EAIhB,gBAAgB,EAChB,cAAc,EACd,KAAK,eAAe,EACvB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"DatabricksWarehouseClient.d.ts","sourceRoot":"","sources":["../../src/warehouseClients/DatabricksWarehouseClient.ts"],"names":[],"mappings":"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"}
|
|
@@ -11,6 +11,7 @@ const common_1 = require("@lightdash/common");
|
|
|
11
11
|
const node_fetch_1 = __importDefault(require("node-fetch"));
|
|
12
12
|
const processPromisesInBatches_1 = require("../utils/processPromisesInBatches");
|
|
13
13
|
const sql_2 = require("../utils/sql");
|
|
14
|
+
const DatabricksWarehouseStartupRetry_1 = require("./DatabricksWarehouseStartupRetry");
|
|
14
15
|
const WarehouseBaseClient_1 = __importDefault(require("./WarehouseBaseClient"));
|
|
15
16
|
const WarehouseBaseSqlBuilder_1 = __importDefault(require("./WarehouseBaseSqlBuilder"));
|
|
16
17
|
/**
|
|
@@ -220,6 +221,15 @@ const DATABRICKS_FETCH_CHUNK_MAX_ROWS = 5000;
|
|
|
220
221
|
const getDatabricksErrorMessage = (error) => error instanceof StatusError_1.default && error.message
|
|
221
222
|
? error.message
|
|
222
223
|
: (0, common_1.getErrorMessage)(error);
|
|
224
|
+
// Don't let close errors override the original error
|
|
225
|
+
const closeQuietly = async (close, context) => {
|
|
226
|
+
try {
|
|
227
|
+
await close();
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
console.error(`Error closing Databricks session on ${context}`, e);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
223
233
|
class DatabricksWarehouseClient extends WarehouseBaseClient_1.default {
|
|
224
234
|
constructor(credentials) {
|
|
225
235
|
super(credentials, new DatabricksSqlBuilder(credentials.startOfWeek));
|
|
@@ -263,7 +273,7 @@ class DatabricksWarehouseClient extends WarehouseBaseClient_1.default {
|
|
|
263
273
|
};
|
|
264
274
|
}
|
|
265
275
|
}
|
|
266
|
-
async
|
|
276
|
+
async openSession(retry) {
|
|
267
277
|
const client = new sql_1.DBSQLClient({});
|
|
268
278
|
let connection;
|
|
269
279
|
let session;
|
|
@@ -286,26 +296,28 @@ class DatabricksWarehouseClient extends WarehouseBaseClient_1.default {
|
|
|
286
296
|
catch (closeError) {
|
|
287
297
|
console.error('Error closing connection after session failure', closeError);
|
|
288
298
|
}
|
|
299
|
+
if (await retry.waitBeforeRetry(e)) {
|
|
300
|
+
return this.openSession(retry);
|
|
301
|
+
}
|
|
289
302
|
throw new common_1.WarehouseConnectionError(getDatabricksErrorMessage(e));
|
|
290
303
|
}
|
|
291
304
|
return {
|
|
292
305
|
session,
|
|
293
306
|
close: async () => {
|
|
294
|
-
|
|
295
|
-
|
|
307
|
+
try {
|
|
308
|
+
await session.close();
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
await connection.close();
|
|
312
|
+
}
|
|
296
313
|
},
|
|
297
314
|
};
|
|
298
315
|
}
|
|
299
|
-
async
|
|
300
|
-
const { session, close } = await this.getSession();
|
|
316
|
+
async streamQueryOnSession(session, sql, streamCallback, options, onRowsStreamed) {
|
|
301
317
|
let query = null;
|
|
302
|
-
let alteredQuery = sql;
|
|
303
|
-
if (options?.tags) {
|
|
304
|
-
alteredQuery = `${alteredQuery}\n-- ${JSON.stringify(options?.tags)}`;
|
|
305
|
-
}
|
|
306
318
|
try {
|
|
307
|
-
if (options
|
|
308
|
-
console.debug(`Setting databricks timezone to ${options
|
|
319
|
+
if (options.timezone) {
|
|
320
|
+
console.debug(`Setting databricks timezone to ${options.timezone}`);
|
|
309
321
|
const setTimezoneOp = await session.executeStatement(`SET TIME ZONE '${options.timezone}'`);
|
|
310
322
|
try {
|
|
311
323
|
await setTimezoneOp.finished(); // ensures SET is applied before main query
|
|
@@ -314,14 +326,14 @@ class DatabricksWarehouseClient extends WarehouseBaseClient_1.default {
|
|
|
314
326
|
await setTimezoneOp.close();
|
|
315
327
|
}
|
|
316
328
|
}
|
|
317
|
-
query = await session.executeStatement(
|
|
329
|
+
query = await session.executeStatement(sql, {
|
|
318
330
|
...(this.enableTimeouts && {
|
|
319
331
|
queryTimeout: DATABRICKS_QUERY_TIMEOUT_SECONDS,
|
|
320
332
|
}),
|
|
321
|
-
ordinalParameters: options
|
|
333
|
+
ordinalParameters: options.values,
|
|
322
334
|
});
|
|
323
|
-
const
|
|
324
|
-
const fields = (
|
|
335
|
+
const querySchema = await query.getSchema();
|
|
336
|
+
const fields = (querySchema?.columns ?? []).reduce((acc, column) => ({
|
|
325
337
|
...acc,
|
|
326
338
|
[column.columnName]: {
|
|
327
339
|
type: convertDataTypeToDimensionType(column.typeDesc.types[0]?.primitiveEntry?.type ??
|
|
@@ -333,73 +345,113 @@ class DatabricksWarehouseClient extends WarehouseBaseClient_1.default {
|
|
|
333
345
|
const chunk = await query.fetchChunk({
|
|
334
346
|
maxRows: DATABRICKS_FETCH_CHUNK_MAX_ROWS,
|
|
335
347
|
});
|
|
348
|
+
if (chunk.length > 0)
|
|
349
|
+
onRowsStreamed();
|
|
336
350
|
// eslint-disable-next-line no-await-in-loop
|
|
337
351
|
await streamCallback({ fields, rows: chunk });
|
|
338
352
|
// eslint-disable-next-line no-await-in-loop
|
|
339
353
|
} while (await query.hasMoreRows());
|
|
340
354
|
}
|
|
341
|
-
catch (e) {
|
|
342
|
-
throw new common_1.WarehouseQueryError(getDatabricksErrorMessage(e));
|
|
343
|
-
}
|
|
344
355
|
finally {
|
|
345
356
|
try {
|
|
346
357
|
if (query)
|
|
347
358
|
await query.close();
|
|
348
|
-
await close();
|
|
349
359
|
}
|
|
350
360
|
catch (e) {
|
|
351
|
-
|
|
352
|
-
console.error('Error closing Databricks session on streamQuery', e);
|
|
361
|
+
console.error('Error closing Databricks query on streamQuery', e);
|
|
353
362
|
}
|
|
354
363
|
}
|
|
355
364
|
}
|
|
356
|
-
async
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
});
|
|
365
|
+
async streamQuery(sql, streamCallback, options) {
|
|
366
|
+
const alteredQuery = options.tags
|
|
367
|
+
? `${sql}\n-- ${JSON.stringify(options.tags)}`
|
|
368
|
+
: sql;
|
|
369
|
+
const retry = new DatabricksWarehouseStartupRetry_1.DatabricksWarehouseStartupRetry();
|
|
370
|
+
let rowsStreamed = false;
|
|
371
|
+
const onRowsStreamed = () => {
|
|
372
|
+
rowsStreamed = true;
|
|
373
|
+
};
|
|
374
|
+
/* eslint-disable no-await-in-loop */
|
|
375
|
+
for (;;) {
|
|
376
|
+
const { session, close } = await this.openSession(retry);
|
|
377
|
+
let error = null;
|
|
378
|
+
try {
|
|
379
|
+
await this.streamQueryOnSession(session, alteredQuery, streamCallback, options, onRowsStreamed);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
error = e;
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
await closeQuietly(close, 'streamQuery');
|
|
387
|
+
}
|
|
388
|
+
// Re-running is only safe while nothing has reached the caller
|
|
389
|
+
if (rowsStreamed || !(await retry.waitBeforeRetry(error))) {
|
|
390
|
+
throw new common_1.WarehouseQueryError(getDatabricksErrorMessage(error));
|
|
391
|
+
}
|
|
384
392
|
}
|
|
385
|
-
|
|
386
|
-
|
|
393
|
+
/* eslint-enable no-await-in-loop */
|
|
394
|
+
}
|
|
395
|
+
static async getTableColumns(session, request) {
|
|
396
|
+
const query = await session.getColumns({
|
|
397
|
+
catalogName: request.database,
|
|
398
|
+
schemaName: request.schema,
|
|
399
|
+
tableName: request.table,
|
|
400
|
+
});
|
|
401
|
+
try {
|
|
402
|
+
return (await query.fetchAll());
|
|
387
403
|
}
|
|
388
404
|
finally {
|
|
389
405
|
try {
|
|
390
|
-
await close();
|
|
406
|
+
await query.close();
|
|
391
407
|
}
|
|
392
408
|
catch (e) {
|
|
393
|
-
|
|
394
|
-
|
|
409
|
+
console.error('Error closing Databricks query on getCatalog', e);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async getCatalog(requests) {
|
|
414
|
+
const retry = new DatabricksWarehouseStartupRetry_1.DatabricksWarehouseStartupRetry();
|
|
415
|
+
const results = new Map();
|
|
416
|
+
let pending = requests.map((request, index) => ({ request, index }));
|
|
417
|
+
/* eslint-disable no-await-in-loop */
|
|
418
|
+
while (pending.length > 0) {
|
|
419
|
+
const { session, close } = await this.openSession(retry);
|
|
420
|
+
// Batches run on one session; a lost session fails the rest of the batch,
|
|
421
|
+
// so let it settle, keep what succeeded and resume on a new session.
|
|
422
|
+
let startupError = null;
|
|
423
|
+
try {
|
|
424
|
+
for (let start = 0; start < pending.length && startupError === null; start += processPromisesInBatches_1.DEFAULT_BATCH_SIZE) {
|
|
425
|
+
const batch = pending.slice(start, start + processPromisesInBatches_1.DEFAULT_BATCH_SIZE);
|
|
426
|
+
const settled = await Promise.allSettled(batch.map(({ request }) => DatabricksWarehouseClient.getTableColumns(session, request)));
|
|
427
|
+
for (const [i, outcome] of settled.entries()) {
|
|
428
|
+
if (outcome.status === 'fulfilled') {
|
|
429
|
+
results.set(batch[i].index, outcome.value);
|
|
430
|
+
}
|
|
431
|
+
else if (!(0, DatabricksWarehouseStartupRetry_1.isDatabricksWarehouseStartingError)(outcome.reason)) {
|
|
432
|
+
throw new common_1.WarehouseQueryError(getDatabricksErrorMessage(outcome.reason));
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
startupError = startupError ?? outcome.reason;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
await closeQuietly(close, 'getCatalog');
|
|
442
|
+
}
|
|
443
|
+
pending = pending.filter(({ index }) => !results.has(index));
|
|
444
|
+
if (startupError !== null &&
|
|
445
|
+
!(await retry.waitBeforeRetry(startupError))) {
|
|
446
|
+
throw new common_1.WarehouseQueryError(getDatabricksErrorMessage(startupError));
|
|
395
447
|
}
|
|
396
448
|
}
|
|
449
|
+
/* eslint-enable no-await-in-loop */
|
|
397
450
|
const catalog = this.catalog || 'DEFAULT';
|
|
398
|
-
return
|
|
399
|
-
const { schema, table } = requests[index];
|
|
451
|
+
return requests.reduce((acc, { schema, table }, index) => {
|
|
400
452
|
acc[catalog][schema] = acc[catalog][schema] || {};
|
|
401
453
|
acc[catalog][schema][table] = {};
|
|
402
|
-
|
|
454
|
+
(results.get(index) ?? []).forEach((col) => {
|
|
403
455
|
acc[catalog][schema][table][col.COLUMN_NAME] = mapFieldType(col.TYPE_NAME);
|
|
404
456
|
(0, common_1.setCatalogTimestampDomain)(acc, catalog, schema, table, col.COLUMN_NAME, (0, exports.getDatabricksTimestampDomain)(col.TYPE_NAME));
|
|
405
457
|
});
|
|
@@ -25,30 +25,65 @@ vi.mock('@databricks/sql', async () => ({
|
|
|
25
25
|
};
|
|
26
26
|
}),
|
|
27
27
|
}));
|
|
28
|
+
const statusError = (errorMessage) => new StatusError_1.default({ statusCode: TCLIService_types_1.TStatusCode.ERROR_STATUS, errorMessage });
|
|
29
|
+
const sessionLostError = () => statusError('requirement failed: Session handle: SessionHandle [01f19fd1-ac81-1e09-bb7c-357018ed26f1] has not been initialized or had already closed.');
|
|
30
|
+
const sessionLostMessage = sessionLostError().message;
|
|
31
|
+
const createOperation = (overrides = {}) => ({
|
|
32
|
+
getSchema: vi.fn(async () => DatabricksWarehouseClient_mock_1.schema),
|
|
33
|
+
fetchChunk: mocks.fetchChunk,
|
|
34
|
+
fetchAll: vi.fn(async () => []),
|
|
35
|
+
hasMoreRows: vi.fn(async () => false),
|
|
36
|
+
close: vi.fn(async () => undefined),
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
const createSession = (overrides = {}) => ({
|
|
40
|
+
executeStatement: vi.fn(async () => createOperation()),
|
|
41
|
+
getColumns: vi.fn(async () => createOperation()),
|
|
42
|
+
close: vi.fn(async () => undefined),
|
|
43
|
+
...overrides,
|
|
44
|
+
});
|
|
45
|
+
// Retries sleep with real backoff; drive the timers while the promise settles.
|
|
46
|
+
const withTimers = async (run) => {
|
|
47
|
+
const settled = run().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
48
|
+
await vi.runAllTimersAsync();
|
|
49
|
+
const result = await settled;
|
|
50
|
+
if (result.ok)
|
|
51
|
+
return result.value;
|
|
52
|
+
throw result.error;
|
|
53
|
+
};
|
|
54
|
+
const tableRequest = (table) => ({
|
|
55
|
+
database: 'database',
|
|
56
|
+
schema: 'schema',
|
|
57
|
+
table,
|
|
58
|
+
});
|
|
28
59
|
describe('DatabricksWarehouseClient', () => {
|
|
29
60
|
beforeEach(() => {
|
|
61
|
+
vi.useFakeTimers();
|
|
62
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
30
63
|
mocks.fetchChunk.mockReset().mockResolvedValue(DatabricksWarehouseClient_mock_1.rows);
|
|
31
64
|
mocks.closeConnection.mockReset().mockResolvedValue(undefined);
|
|
32
|
-
mocks.openSession.mockReset().mockResolvedValue(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
close: vi.fn(async () => undefined),
|
|
38
|
-
})),
|
|
39
|
-
close: vi.fn(async () => undefined),
|
|
40
|
-
});
|
|
65
|
+
mocks.openSession.mockReset().mockResolvedValue(createSession());
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.useRealTimers();
|
|
69
|
+
vi.restoreAllMocks();
|
|
41
70
|
});
|
|
42
71
|
it('surfaces Databricks status messages when opening a session fails', async () => {
|
|
43
|
-
const message = '
|
|
44
|
-
mocks.openSession.mockRejectedValueOnce(
|
|
45
|
-
statusCode: TCLIService_types_1.TStatusCode.ERROR_STATUS,
|
|
46
|
-
errorMessage: message,
|
|
47
|
-
}));
|
|
72
|
+
const message = 'PERMISSION_DENIED: User does not have USE CATALOG on Catalog';
|
|
73
|
+
mocks.openSession.mockRejectedValueOnce(statusError(message));
|
|
48
74
|
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
49
75
|
await expect(warehouse.runQuery('fake sql')).rejects.toMatchObject({
|
|
50
76
|
message,
|
|
51
77
|
});
|
|
78
|
+
expect(mocks.openSession).toHaveBeenCalledOnce();
|
|
79
|
+
});
|
|
80
|
+
it('retries opening a session while the warehouse is starting, then surfaces the error', async () => {
|
|
81
|
+
const message = 'SQL warehouse xyz is not ready to accept connections (current state: STARTING)';
|
|
82
|
+
mocks.openSession.mockRejectedValue(statusError(message));
|
|
83
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
84
|
+
await expect(withTimers(() => warehouse.runQuery('fake sql'))).rejects.toMatchObject({ message });
|
|
85
|
+
// 2+4+8+16 then 30s waits fit 23 retries in the 10 minute deadline
|
|
86
|
+
expect(mocks.openSession).toHaveBeenCalledTimes(24);
|
|
52
87
|
});
|
|
53
88
|
it('expect query fields and rows', async () => {
|
|
54
89
|
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
@@ -61,6 +96,146 @@ describe('DatabricksWarehouseClient', () => {
|
|
|
61
96
|
await warehouse.runQuery('fake sql');
|
|
62
97
|
expect(mocks.fetchChunk).toHaveBeenCalledWith({ maxRows: 5000 });
|
|
63
98
|
});
|
|
99
|
+
describe('session lost before rows are streamed', () => {
|
|
100
|
+
it('reopens the session and re-runs the query', async () => {
|
|
101
|
+
const firstSession = createSession({
|
|
102
|
+
executeStatement: vi.fn(() => Promise.reject(sessionLostError())),
|
|
103
|
+
});
|
|
104
|
+
mocks.openSession
|
|
105
|
+
.mockResolvedValueOnce(firstSession)
|
|
106
|
+
.mockResolvedValueOnce(createSession());
|
|
107
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
108
|
+
const results = await withTimers(() => warehouse.runQuery('fake sql'));
|
|
109
|
+
expect(results.rows).toEqual(DatabricksWarehouseClient_mock_1.rows);
|
|
110
|
+
expect(mocks.openSession).toHaveBeenCalledTimes(2);
|
|
111
|
+
expect(firstSession.close).toHaveBeenCalledOnce();
|
|
112
|
+
});
|
|
113
|
+
it('re-runs the query when only an empty chunk was emitted', async () => {
|
|
114
|
+
mocks.openSession
|
|
115
|
+
.mockResolvedValueOnce(createSession({
|
|
116
|
+
executeStatement: vi.fn(async () => createOperation({
|
|
117
|
+
fetchChunk: vi.fn(async () => []),
|
|
118
|
+
hasMoreRows: vi.fn(() => Promise.reject(sessionLostError())),
|
|
119
|
+
})),
|
|
120
|
+
}))
|
|
121
|
+
.mockResolvedValueOnce(createSession());
|
|
122
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
123
|
+
const results = await withTimers(() => warehouse.runQuery('fake sql'));
|
|
124
|
+
expect(results.rows).toEqual(DatabricksWarehouseClient_mock_1.rows);
|
|
125
|
+
expect(mocks.openSession).toHaveBeenCalledTimes(2);
|
|
126
|
+
});
|
|
127
|
+
it('gives up once the retry budget is spent', async () => {
|
|
128
|
+
mocks.openSession.mockResolvedValue(createSession({
|
|
129
|
+
executeStatement: vi.fn(() => Promise.reject(sessionLostError())),
|
|
130
|
+
}));
|
|
131
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
132
|
+
await expect(withTimers(() => warehouse.runQuery('fake sql'))).rejects.toMatchObject({ message: sessionLostMessage });
|
|
133
|
+
expect(mocks.openSession).toHaveBeenCalledTimes(24);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
it('does not re-run the query once rows have been streamed', async () => {
|
|
137
|
+
const streamCallback = vi.fn();
|
|
138
|
+
mocks.openSession.mockResolvedValueOnce(createSession({
|
|
139
|
+
executeStatement: vi.fn(async () => createOperation({
|
|
140
|
+
hasMoreRows: vi.fn(() => Promise.reject(sessionLostError())),
|
|
141
|
+
})),
|
|
142
|
+
}));
|
|
143
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
144
|
+
await expect(withTimers(() => warehouse.streamQuery('fake sql', streamCallback, {}))).rejects.toMatchObject({ message: sessionLostMessage });
|
|
145
|
+
expect(streamCallback).toHaveBeenCalledOnce();
|
|
146
|
+
expect(mocks.openSession).toHaveBeenCalledOnce();
|
|
147
|
+
});
|
|
148
|
+
it('does not retry SQL errors', async () => {
|
|
149
|
+
mocks.openSession.mockResolvedValueOnce(createSession({
|
|
150
|
+
executeStatement: vi.fn(() => Promise.reject(statusError('Syntax error near FROM'))),
|
|
151
|
+
}));
|
|
152
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
153
|
+
await expect(withTimers(() => warehouse.runQuery('fake sql'))).rejects.toMatchObject({ message: 'Syntax error near FROM' });
|
|
154
|
+
expect(mocks.openSession).toHaveBeenCalledOnce();
|
|
155
|
+
});
|
|
156
|
+
describe('getCatalog', () => {
|
|
157
|
+
const columns = (name, type) => [
|
|
158
|
+
{ COLUMN_NAME: name, TYPE_NAME: type },
|
|
159
|
+
];
|
|
160
|
+
const columnsSession = (resolve, lostTables) => createSession({
|
|
161
|
+
getColumns: vi.fn(async ({ tableName }) => lostTables.has(tableName)
|
|
162
|
+
? Promise.reject(sessionLostError())
|
|
163
|
+
: createOperation({
|
|
164
|
+
fetchAll: vi.fn(async () => resolve(tableName)),
|
|
165
|
+
})),
|
|
166
|
+
});
|
|
167
|
+
it('resumes the remaining tables on a replacement session', async () => {
|
|
168
|
+
const firstSession = columnsSession(() => columns('id', 'BIGINT'), new Set(['table_two', 'table_three']));
|
|
169
|
+
const secondSession = columnsSession(() => columns('name', 'STRING'), new Set());
|
|
170
|
+
mocks.openSession
|
|
171
|
+
.mockResolvedValueOnce(firstSession)
|
|
172
|
+
.mockResolvedValueOnce(secondSession);
|
|
173
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
174
|
+
const catalog = await withTimers(() => warehouse.getCatalog([
|
|
175
|
+
tableRequest('table_one'),
|
|
176
|
+
tableRequest('table_two'),
|
|
177
|
+
tableRequest('table_three'),
|
|
178
|
+
]));
|
|
179
|
+
expect(catalog.DEFAULT.schema).toEqual({
|
|
180
|
+
table_one: { id: 'number' },
|
|
181
|
+
table_two: { name: 'string' },
|
|
182
|
+
table_three: { name: 'string' },
|
|
183
|
+
});
|
|
184
|
+
expect(firstSession.getColumns).toHaveBeenCalledTimes(3);
|
|
185
|
+
expect(firstSession.close).toHaveBeenCalledOnce();
|
|
186
|
+
expect(secondSession.getColumns.mock.calls.map((call) => call[0].tableName)).toEqual(['table_two', 'table_three']);
|
|
187
|
+
});
|
|
188
|
+
it('lets the in-flight batch settle before closing the lost session', async () => {
|
|
189
|
+
const order = [];
|
|
190
|
+
const firstSession = createSession({
|
|
191
|
+
getColumns: vi.fn(({ tableName }) => new Promise((resolve, reject) => {
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
order.push(`getColumns:${tableName}`);
|
|
194
|
+
reject(sessionLostError());
|
|
195
|
+
}, tableName === 'table_one' ? 10 : 100);
|
|
196
|
+
})),
|
|
197
|
+
});
|
|
198
|
+
firstSession.close.mockImplementation(async () => {
|
|
199
|
+
order.push('close');
|
|
200
|
+
});
|
|
201
|
+
mocks.openSession
|
|
202
|
+
.mockResolvedValueOnce(firstSession)
|
|
203
|
+
.mockResolvedValueOnce(createSession());
|
|
204
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
205
|
+
await withTimers(() => warehouse.getCatalog([
|
|
206
|
+
tableRequest('table_one'),
|
|
207
|
+
tableRequest('table_two'),
|
|
208
|
+
]));
|
|
209
|
+
expect(order).toEqual([
|
|
210
|
+
'getColumns:table_one',
|
|
211
|
+
'getColumns:table_two',
|
|
212
|
+
'close',
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
it('shares one retry budget across the whole fetch', async () => {
|
|
216
|
+
mocks.openSession.mockResolvedValue(columnsSession(() => [], new Set(['table_one'])));
|
|
217
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
218
|
+
await expect(withTimers(() => warehouse.getCatalog([tableRequest('table_one')]))).rejects.toMatchObject({ message: sessionLostMessage });
|
|
219
|
+
expect(mocks.openSession).toHaveBeenCalledTimes(24);
|
|
220
|
+
});
|
|
221
|
+
it('fails fast on errors that are not warehouse startup errors', async () => {
|
|
222
|
+
const session = createSession({
|
|
223
|
+
getColumns: vi.fn(async ({ tableName }) => tableName === 'table_two'
|
|
224
|
+
? Promise.reject(statusError('PERMISSION_DENIED on table_two'))
|
|
225
|
+
: createOperation()),
|
|
226
|
+
});
|
|
227
|
+
mocks.openSession.mockResolvedValueOnce(session);
|
|
228
|
+
const warehouse = new DatabricksWarehouseClient_1.DatabricksWarehouseClient(DatabricksWarehouseClient_mock_1.credentials);
|
|
229
|
+
await expect(withTimers(() => warehouse.getCatalog([
|
|
230
|
+
tableRequest('table_one'),
|
|
231
|
+
tableRequest('table_two'),
|
|
232
|
+
]))).rejects.toMatchObject({
|
|
233
|
+
message: 'PERMISSION_DENIED on table_two',
|
|
234
|
+
});
|
|
235
|
+
expect(mocks.openSession).toHaveBeenCalledOnce();
|
|
236
|
+
expect(session.close).toHaveBeenCalledOnce();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
64
239
|
});
|
|
65
240
|
describe('DatabricksSqlBuilder escaping', () => {
|
|
66
241
|
const databricksSqlBuilder = new DatabricksWarehouseClient_1.DatabricksSqlBuilder();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import HiveDriverError from '@databricks/sql/dist/errors/HiveDriverError';
|
|
2
|
+
import StatusError from '@databricks/sql/dist/errors/StatusError';
|
|
3
|
+
export declare const isDatabricksWarehouseStartingError: (error: unknown) => error is StatusError | HiveDriverError;
|
|
4
|
+
export declare const DATABRICKS_WAREHOUSE_STARTUP_RETRY: {
|
|
5
|
+
readonly initialDelayMs: 2000;
|
|
6
|
+
readonly maxDelayMs: 30000;
|
|
7
|
+
readonly deadlineMs: 600000;
|
|
8
|
+
};
|
|
9
|
+
/** Delay before retry `attempt` (1-based), or null once the deadline would pass. */
|
|
10
|
+
export declare const getDatabricksWarehouseStartupRetryDelayMs: (attempt: number, elapsedMs: number) => number | null;
|
|
11
|
+
/** One retry budget, shared across every session opened for a single operation. */
|
|
12
|
+
export declare class DatabricksWarehouseStartupRetry {
|
|
13
|
+
private attempt;
|
|
14
|
+
private firstFailureAt;
|
|
15
|
+
/** Waits with backoff if the error is a warehouse startup error and budget remains. */
|
|
16
|
+
waitBeforeRetry(error: unknown): Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=DatabricksWarehouseStartupRetry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DatabricksWarehouseStartupRetry.d.ts","sourceRoot":"","sources":["../../src/warehouseClients/DatabricksWarehouseStartupRetry.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,MAAM,6CAA6C,CAAC;AAE1E,OAAO,WAAW,MAAM,yCAAyC,CAAC;AAclE,eAAO,MAAM,kCAAkC,UACpC,OAAO,KACf,KAAK,IAAI,WAAW,GAAG,eAMrB,CAAC;AAKN,eAAO,MAAM,kCAAkC;aAC3C,cAAc,EAAE,IAAK;aACrB,UAAU,EAAE,KAAM;aAClB,UAAU,EAAE,MAAO;CACb,CAAC;AAEX,oFAAoF;AACpF,eAAO,MAAM,yCAAyC,YACzC,MAAM,aACJ,MAAM,KAClB,MAAM,GAAG,IAKX,CAAC;AAOF,mFAAmF;AACnF,qBAAa,+BAA+B;IACxC,OAAO,CAAC,OAAO,CAAK;IAEpB,OAAO,CAAC,cAAc,CAAuB;IAE7C,uFAAuF;IACjF,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAoBtD;CACJ"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DatabricksWarehouseStartupRetry = exports.getDatabricksWarehouseStartupRetryDelayMs = exports.DATABRICKS_WAREHOUSE_STARTUP_RETRY = exports.isDatabricksWarehouseStartingError = void 0;
|
|
7
|
+
const HiveDriverError_1 = __importDefault(require("@databricks/sql/dist/errors/HiveDriverError"));
|
|
8
|
+
const OperationStateError_1 = __importDefault(require("@databricks/sql/dist/errors/OperationStateError"));
|
|
9
|
+
const StatusError_1 = __importDefault(require("@databricks/sql/dist/errors/StatusError"));
|
|
10
|
+
// The driver surfaces server errors with no usable code, so the message is the only
|
|
11
|
+
// signal. Explicit allowlist so auth, permission and SQL errors fail fast.
|
|
12
|
+
const DATABRICKS_WAREHOUSE_STARTING_MESSAGES = [
|
|
13
|
+
// Pro/classic: OpenSession succeeds while stopped, then every op is rejected
|
|
14
|
+
/Session handle: SessionHandle \[[^\]]*\] has not been initialized or had already closed/,
|
|
15
|
+
// Cluster nodes still booting when the session is initialised
|
|
16
|
+
/Couldn't create directory \/local_disk0\//,
|
|
17
|
+
/is not ready to accept connections \(current state: STARTING\)/,
|
|
18
|
+
// Pro/classic STARTING: 503 until up; the driver gives up after ~30s of HTTP retries
|
|
19
|
+
/^Hive driver: 503 when connecting to resource/,
|
|
20
|
+
];
|
|
21
|
+
const isDatabricksWarehouseStartingError = (error) => (error instanceof StatusError_1.default ||
|
|
22
|
+
error instanceof OperationStateError_1.default ||
|
|
23
|
+
error instanceof HiveDriverError_1.default) &&
|
|
24
|
+
DATABRICKS_WAREHOUSE_STARTING_MESSAGES.some((pattern) => pattern.test(error.message));
|
|
25
|
+
exports.isDatabricksWarehouseStartingError = isDatabricksWarehouseStartingError;
|
|
26
|
+
// Serverless starts in seconds; pro/classic take minutes and can be "Start-up Delayed".
|
|
27
|
+
// The deadline is wall clock from the first failure, since each attempt also spends
|
|
28
|
+
// time inside the driver's own HTTP retries.
|
|
29
|
+
exports.DATABRICKS_WAREHOUSE_STARTUP_RETRY = {
|
|
30
|
+
initialDelayMs: 2000,
|
|
31
|
+
maxDelayMs: 30000,
|
|
32
|
+
deadlineMs: 600000,
|
|
33
|
+
};
|
|
34
|
+
/** Delay before retry `attempt` (1-based), or null once the deadline would pass. */
|
|
35
|
+
const getDatabricksWarehouseStartupRetryDelayMs = (attempt, elapsedMs) => {
|
|
36
|
+
const { initialDelayMs, maxDelayMs, deadlineMs } = exports.DATABRICKS_WAREHOUSE_STARTUP_RETRY;
|
|
37
|
+
const delayMs = Math.min(initialDelayMs * 2 ** (attempt - 1), maxDelayMs);
|
|
38
|
+
return elapsedMs + delayMs <= deadlineMs ? delayMs : null;
|
|
39
|
+
};
|
|
40
|
+
exports.getDatabricksWarehouseStartupRetryDelayMs = getDatabricksWarehouseStartupRetryDelayMs;
|
|
41
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
42
|
+
setTimeout(resolve, ms);
|
|
43
|
+
});
|
|
44
|
+
/** One retry budget, shared across every session opened for a single operation. */
|
|
45
|
+
class DatabricksWarehouseStartupRetry {
|
|
46
|
+
constructor() {
|
|
47
|
+
this.attempt = 0;
|
|
48
|
+
this.firstFailureAt = null;
|
|
49
|
+
}
|
|
50
|
+
/** Waits with backoff if the error is a warehouse startup error and budget remains. */
|
|
51
|
+
async waitBeforeRetry(error) {
|
|
52
|
+
if (!(0, exports.isDatabricksWarehouseStartingError)(error))
|
|
53
|
+
return false;
|
|
54
|
+
this.firstFailureAt = this.firstFailureAt ?? Date.now();
|
|
55
|
+
const elapsedMs = Date.now() - this.firstFailureAt;
|
|
56
|
+
const delayMs = (0, exports.getDatabricksWarehouseStartupRetryDelayMs)(this.attempt + 1, elapsedMs);
|
|
57
|
+
if (delayMs === null)
|
|
58
|
+
return false;
|
|
59
|
+
this.attempt += 1;
|
|
60
|
+
console.warn(`Databricks SQL warehouse is not ready (${error.message}). Waiting ${delayMs / 1000}s before retry ${this.attempt} (${Math.round(elapsedMs / 1000)}s of ${exports.DATABRICKS_WAREHOUSE_STARTUP_RETRY.deadlineMs / 1000}s deadline used)`);
|
|
61
|
+
await sleep(delayMs);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.DatabricksWarehouseStartupRetry = DatabricksWarehouseStartupRetry;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DatabricksWarehouseStartupRetry.test.d.ts","sourceRoot":"","sources":["../../src/warehouseClients/DatabricksWarehouseStartupRetry.test.ts"],"names":[],"mappings":""}
|