@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.
@@ -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 getSession;
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;AAM5C,OAAO,mBAAmB,MAAM,uBAAuB,CAAC;AACxD,OAAO,uBAAuB,MAAM,2BAA2B,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,kCAAkC,mBAAmB,CAAC;AAQnE,eAAO,MAAM,4BAA4B,aAC3B,MAAM,GAAG,SAAS,KAC7B,OAAsE,CAAC;AAoJ1E,eAAO,MAAM,4BAA4B,SAC/B,MAAM,KACb,eAAe,GAAG,SASpB,CAAC;AAEF,qBAAa,oBAAqB,SAAQ,uBAAuB;IAC7D,QAAQ,CAAC,IAAI,6BAA6B;IAE1C,cAAc,IAAI,mBAAmB,CAEpC;IAED,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAShD;IAED,iBAAiB,IAAI,MAAM,CAE1B;IAED,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAiBlC;IAED,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAI5D;IAED,uBAAuB,CACnB,iBAAiB,EAAE,MAAM,EACzB,eAAe,EAAE,MAAM,GACxB,MAAM,CAGR;IAED,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrC;IAED,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAGrC;IAED,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAO1D;CACJ;AAaD,qBAAa,yBAA0B,SAAQ,mBAAmB,CAAC,2BAA2B,CAAC;IAC3F,MAAM,EAAE,MAAM,CAAC;IAEf,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,iBAAiB,EAAE,iBAAiB,CAAC;IAErC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAU;IAEzC,YAAY,WAAW,EAAE,2BAA2B,EA+CnD;YAEa,UAAU;IAqClB,WAAW,CACb,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,CAAC,IAAI,EAAE,gBAAgB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EAChE,OAAO,EAAE;QACL,MAAM,CAAC,EAAE,OAAO,EAAE,CAAC;QACnB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;KACrB,GACF,OAAO,CAAC,IAAI,CAAC,CAwEf;IAEK,UAAU,CACZ,QAAQ,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;KACjB,EAAE,6BA0EN;IAEK,YAAY;;;;SAajB;IAEK,SAAS,CACX,MAAM,CAAC,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,OAAO,CAAC,gBAAgB,CAAC,CAoB3B;IAEK,SAAS,CACX,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,EACjB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,OAAO,CAAC,gBAAgB,CAAC,CAyB3B;CACJ;AAED;;GAEG;AACH,eAAO,MAAM,kCAAkC,SACrC,MAAM,YACF,MAAM,gBACF,MAAM,KACrB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CA+BxD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,2BAA2B,SAC9B,MAAM,YACF,MAAM,gBACF,MAAM,iBACL,MAAM,KACtB,OAAO,CAAC;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACrB,CAqCA,CAAC"}
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;AAO5C,OAAO,mBAAmB,MAAM,uBAAuB,CAAC;AACxD,OAAO,uBAAuB,MAAM,2BAA2B,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,kCAAkC,mBAAmB,CAAC;AAQnE,eAAO,MAAM,4BAA4B,aAC3B,MAAM,GAAG,SAAS,KAC7B,OAAsE,CAAC;AAoJ1E,eAAO,MAAM,4BAA4B,SAC/B,MAAM,KACb,eAAe,GAAG,SASpB,CAAC;AAEF,qBAAa,oBAAqB,SAAQ,uBAAuB;IAC7D,QAAQ,CAAC,IAAI,6BAA6B;IAE1C,cAAc,IAAI,mBAAmB,CAEpC;IAED,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAShD;IAED,iBAAiB,IAAI,MAAM,CAE1B;IAED,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAiBlC;IAED,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAI5D;IAED,uBAAuB,CACnB,iBAAiB,EAAE,MAAM,EACzB,eAAe,EAAE,MAAM,GACxB,MAAM,CAGR;IAED,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrC;IAED,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAGrC;IAED,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAO1D;CACJ;AA2BD,qBAAa,yBAA0B,SAAQ,mBAAmB,CAAC,2BAA2B,CAAC;IAC3F,MAAM,EAAE,MAAM,CAAC;IAEf,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,iBAAiB,EAAE,iBAAiB,CAAC;IAErC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAU;IAEzC,YAAY,WAAW,EAAE,2BAA2B,EA+CnD;YAEa,WAAW;YA6CX,oBAAoB;IAuE5B,WAAW,CACb,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,CAAC,IAAI,EAAE,gBAAgB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EAChE,OAAO,EAAE;QACL,MAAM,CAAC,EAAE,OAAO,EAAE,CAAC;QACnB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;KACrB,GACF,OAAO,CAAC,IAAI,CAAC,CAkCf;mBAEoB,eAAe;IAuB9B,UAAU,CACZ,QAAQ,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;KACjB,EAAE,6BAkFN;IAEK,YAAY;;;;SAajB;IAEK,SAAS,CACX,MAAM,CAAC,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,OAAO,CAAC,gBAAgB,CAAC,CAoB3B;IAEK,SAAS,CACX,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,EACjB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,OAAO,CAAC,gBAAgB,CAAC,CAyB3B;CACJ;AAED;;GAEG;AACH,eAAO,MAAM,kCAAkC,SACrC,MAAM,YACF,MAAM,gBACF,MAAM,KACrB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CA+BxD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,2BAA2B,SAC9B,MAAM,YACF,MAAM,gBACF,MAAM,iBACL,MAAM,KACtB,OAAO,CAAC;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACrB,CAqCA,CAAC"}
@@ -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 getSession() {
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
- await session.close();
295
- await connection.close();
307
+ try {
308
+ await session.close();
309
+ }
310
+ finally {
311
+ await connection.close();
312
+ }
296
313
  },
297
314
  };
298
315
  }
299
- async streamQuery(sql, streamCallback, options) {
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?.timezone) {
308
- console.debug(`Setting databricks timezone to ${options?.timezone}`);
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(alteredQuery, {
329
+ query = await session.executeStatement(sql, {
318
330
  ...(this.enableTimeouts && {
319
331
  queryTimeout: DATABRICKS_QUERY_TIMEOUT_SECONDS,
320
332
  }),
321
- ordinalParameters: options?.values,
333
+ ordinalParameters: options.values,
322
334
  });
323
- const schema = await query.getSchema();
324
- const fields = (schema?.columns ?? []).reduce((acc, column) => ({
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
- // Only console error. Don't allow close errors to override the original error
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 getCatalog(requests) {
357
- const { session, close } = await this.getSession();
358
- let results;
359
- try {
360
- results = await (0, processPromisesInBatches_1.processPromisesInBatches)(requests, processPromisesInBatches_1.DEFAULT_BATCH_SIZE, async (request) => {
361
- let query = null;
362
- try {
363
- query = await session.getColumns({
364
- catalogName: request.database,
365
- schemaName: request.schema,
366
- tableName: request.table,
367
- });
368
- return (await query.fetchAll());
369
- }
370
- catch (e) {
371
- throw new common_1.WarehouseQueryError(getDatabricksErrorMessage(e));
372
- }
373
- finally {
374
- try {
375
- if (query)
376
- await query.close();
377
- }
378
- catch (e) {
379
- // Only console error. Don't allow close errors to override the original error
380
- console.error('Error closing Databricks query on getCatalog', e);
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
- catch (e) {
386
- throw new common_1.WarehouseQueryError(getDatabricksErrorMessage(e));
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
- // Only console error. Don't allow close errors to override the original error
394
- console.error('Error closing Databricks session on getCatalog', e);
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 results.reduce((acc, result, index) => {
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
- result.forEach((col) => {
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
- executeStatement: vi.fn(() => ({
34
- getSchema: vi.fn(async () => DatabricksWarehouseClient_mock_1.schema),
35
- fetchChunk: mocks.fetchChunk,
36
- hasMoreRows: vi.fn(async () => false),
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 = 'SQL warehouse xyz is not ready to accept connections (current state: STARTING)';
44
- mocks.openSession.mockRejectedValueOnce(new StatusError_1.default({
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=DatabricksWarehouseStartupRetry.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DatabricksWarehouseStartupRetry.test.d.ts","sourceRoot":"","sources":["../../src/warehouseClients/DatabricksWarehouseStartupRetry.test.ts"],"names":[],"mappings":""}