Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Add `hasResultSet` to `getResultMetadata()`. It is `false` for statements that return no result set, such as `CREATE TABLE`, and `true` for queries even when they return no rows; on the Thrift backend it is `undefined` when Arrow results are disabled. (#309)
- CloudFetch retry-exhaustion errors now identify the download and the last failure: the `RetryError` message reads `CloudFetch result download failed: <original message> (last error: <cause>)`. `<cause>` is `HTTP <status>: <statusText>` (or `HTTP <status>` when the status text is empty) for HTTP failures, and `network error <code>`, `network error (<type>)` (e.g. `aborted`, when there is no `code`) or a bare `network error` for network failures (the raw network-error message, which can contain the presigned URL, is not copied). If no cause can be determined, the ` (last error: …)` suffix is omitted. The `RetryError` instance, `errorCode` and `payload` (including the node-fetch `Request` / `Response`) are unchanged. (#471)
- Report untyped NULL columns as STRING in kernel result metadata, matching Thrift. (#470)
- Reject non-scalar query parameter values at bind time with a `ParameterError` on both the Thrift and kernel backends, instead of stringifying them (which could leave a Thrift operation stuck before FINISHED). Newly rejected on the Thrift backend: arrays, plain objects, boxed primitives (e.g. `new String('x')`, `new Number(1)`), functions, symbols, and a `DBSQLParameter` that wraps another `DBSQLParameter`. Primitives, `Date`, `Int64`, and `DBSQLParameter` are still accepted.
Expand Down
13 changes: 13 additions & 0 deletions lib/contracts/ResultMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,17 @@ export interface ResultMetadata {

/** True iff the operation is a staging (PUT/GET/REMOVE) operation. */
isStagingOperation: boolean;

/**
* Whether the statement produced a result set. `false` for statements that
* return no columns, such as DDL (`CREATE TABLE`, `DROP TABLE`) or `USE`;
* `true` for queries, even when they return zero rows. DML returns a result
* set with affected-row counts. Use this rather than `schema`, which on the
* Thrift backend reports a placeholder `Result` column for statements without
* a result set.
*
* `undefined` when the backend cannot tell: on the Thrift backend this
* requires Arrow results (`arrowEnabled`, the default).
*/
hasResultSet?: boolean;
}
2 changes: 2 additions & 0 deletions lib/kernel/KernelOperationBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@ export default class KernelOperationBackend implements IOperationBackend {
// "Duration" (18)`. Matches what the per-batch fetch path already does.
arrowSchema: patchIpcBytes(arrowSchemaIpc.ipcBytes),
isStagingOperation: false,
// SEA reports a zero-column schema for statements without a result set (e.g. DDL).
hasResultSet: arrowSchema.fields.length > 0,
};
this.metadata = meta;
return meta;
Expand Down
7 changes: 6 additions & 1 deletion lib/thrift-backend/EmptyOperationBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export default class EmptyOperationBackend implements IOperationBackend {
}

public async getResultMetadata(): Promise<ResultMetadata> {
return { schema: this.schema, resultFormat: ResultFormat.ColumnBased, isStagingOperation: false };
return {
schema: this.schema,
resultFormat: ResultFormat.ColumnBased,
isStagingOperation: false,
hasResultSet: true,
};
}

public async cancel(): Promise<Status> {
Expand Down
40 changes: 39 additions & 1 deletion lib/thrift-backend/ThriftOperationBackend.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { stringify, NIL } from 'uuid';
import { MessageReader } from 'apache-arrow';
import {
TGetOperationStatusResp,
TOperationHandle,
TOperationType,
TSparkDirectResults,
TGetResultSetMetadataResp,
TSparkRowSetType,
Expand Down Expand Up @@ -101,6 +103,8 @@ export default class ThriftOperationBackend implements IOperationBackend {

private resultHandler?: ResultSlicer<any>;

private arrowSchemaHasFields?: { arrowSchema: Buffer; value?: boolean };

constructor({
handle,
directResults,
Expand Down Expand Up @@ -453,14 +457,48 @@ export default class ThriftOperationBackend implements IOperationBackend {
};
}

// eslint-disable-next-line class-methods-use-this
private adaptResultMetadata(response: TGetResultSetMetadataResp): ResultMetadata {
return {
schema: response.schema,
resultFormat: thriftRowSetTypeToResultFormat(definedOrError(response.resultFormat)),
lz4Compressed: response.lz4Compressed,
arrowSchema: response.arrowSchema,
isStagingOperation: Boolean(response.isStagingOperation),
hasResultSet: this.resultSetPresence(response.arrowSchema),
};
}

// Warehouses set the handle's `hasResultSet` even for DDL and give it a
// placeholder `Result` column in `schema`; only the Arrow schema has no fields.
// Catalog operations always return a result set.
private resultSetPresence(arrowSchema?: Buffer): boolean | undefined {
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
const { operationType } = this;
if (
operationType !== undefined &&
operationType !== TOperationType.EXECUTE_STATEMENT &&
operationType !== TOperationType.UNKNOWN
) {
return true;
}
if (this.operationHandle.hasResultSet === false) {
return false;
}
if (!arrowSchema) {
return undefined;
}
if (this.arrowSchemaHasFields?.arrowSchema !== arrowSchema) {
let value: boolean | undefined;
try {
const schema = new MessageReader(arrowSchema).readSchema();
value = schema ? schema.fields.length > 0 : undefined;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.context
.getLogger()
.log(LogLevel.debug, `Cannot decode the Arrow schema of operation ${this.id}: ${message}`);
}
this.arrowSchemaHasFields = { arrowSchema, value };
}
return this.arrowSchemaHasFields.value;
}
}
99 changes: 99 additions & 0 deletions tests/e2e/result_set_presence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect } from 'chai';
import sinon from 'sinon';
import { DBSQLClient } from '../../lib';
import { ConnectionOptions } from '../../lib/contracts/IDBSQLClient';
import { ClientConfig } from '../../lib/contracts/IClientContext';
import { InternalConnectionOptions } from '../../lib/contracts/InternalConnectionOptions';
import IDBSQLSession from '../../lib/contracts/IDBSQLSession';
import { tryGetKernelNative } from '../../lib/kernel/KernelNativeLoader';
import config from './utils/config';

async function openSession(useKernel: boolean, customConfig: Partial<ClientConfig> = {}) {
const client = new DBSQLClient();
if (Object.keys(customConfig).length > 0) {
const clientConfig = client.getConfig();
sinon.stub(client, 'getConfig').returns({ ...clientConfig, ...customConfig });
}
await client.connect({
host: config.host,
path: config.path,
token: config.token,
useKernel,
} as ConnectionOptions & InternalConnectionOptions);
const session = await client.openSession({ initialCatalog: config.catalog, initialSchema: config.schema });
return { client, session };
}

async function hasResultSet(session: IDBSQLSession, statement: string) {
const operation = await session.executeStatement(statement);
try {
const { hasResultSet: value } = await operation.getResultMetadata();
await operation.fetchAll();
return value;
} finally {
await operation.close();
}
}

const backends = [
{ name: 'Thrift', useKernel: false },
{ name: 'kernel', useKernel: true, skip: tryGetKernelNative() === undefined },
];

backends.forEach(({ name, useKernel, skip }) => {
describe(`getResultMetadata().hasResultSet (${name})`, () => {
const table = `${config.catalog}.${config.schema}.result_set_presence_${config.tableSuffix}_${name}`;
let client: DBSQLClient;
let session: IDBSQLSession;

before(async function setup() {
if (skip) {
// eslint-disable-next-line no-invalid-this
this.skip();
}
({ client, session } = await openSession(useKernel));
});

after(async () => {
try {
if (session) {
await hasResultSet(session, `DROP TABLE IF EXISTS ${table}`);
await session.close();
}
} finally {
await client?.close();
}
});

afterEach(() => sinon.restore());

it('is false for DDL and true for an empty SELECT', async () => {
expect(await hasResultSet(session, `CREATE TABLE IF NOT EXISTS ${table} (col1 INT)`)).to.be.false;
expect(await hasResultSet(session, `SELECT 'sample' AS Result WHERE 1 = 0`)).to.be.true;
expect(await hasResultSet(session, 'SELECT 1 AS one')).to.be.true;
expect(await hasResultSet(session, `INSERT INTO ${table} VALUES (1)`)).to.be.true;
expect(await hasResultSet(session, `USE SCHEMA ${config.schema}`)).to.be.false;
});

it('is true for catalog operations', async () => {
const operation = await session.getTables({ catalogName: config.catalog, schemaName: config.schema });
try {
expect((await operation.getResultMetadata()).hasResultSet).to.be.true;
} finally {
await operation.close();
}
});

if (!useKernel) {
it('is undefined when Arrow results are disabled', async () => {
const columnar = await openSession(false, { arrowEnabled: false });
try {
expect(await hasResultSet(columnar.session, `SELECT 'sample' AS Result WHERE 1 = 0`)).to.be.undefined;
} finally {
await columnar.session.close();
await columnar.client.close();
}
});
}
});
});
123 changes: 123 additions & 0 deletions tests/unit/DBSQLOperation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import ArrowResultConverter from '../../lib/result/ArrowResultConverter';
import ArrowResultHandler from '../../lib/result/ArrowResultHandler';
import CloudFetchResultHandler from '../../lib/result/CloudFetchResultHandler';
import ResultSlicer from '../../lib/result/ResultSlicer';
import { LogLevel } from '../../lib/contracts/IDBSQLLogger';

import ClientContextStub from './.stubs/ClientContextStub';
import { createOperationForTest } from './.stubs/createOperationForTest';
Expand Down Expand Up @@ -790,6 +791,128 @@ describe('DBSQLOperation', () => {
});
});

describe('getResultMetadata hasResultSet', () => {
// Arrow schemas captured from a live SQL warehouse. Both statements get the
// same placeholder Thrift schema (one `Result STRING` column); only the Arrow
// schema shows that the DDL produced no columns.
const createTableArrowSchema = Buffer.from(
'ffffffff400000001000000000000a000e0006000d0008000a000000000004001000000000010a000c000000080004000a00000008' +
'00000008000000000000000000000000000000',
'hex',
);
const emptySelectArrowSchema = Buffer.from(
'ffffffff080100001000000000000a000e0006000d0008000a000000000004001000000000010a000c000000080004000a00000008' +
'000000080000000000000001000000180000000000120018001400000013000c00000008000400120000001400000090000000940000' +
'000000000590000000020000004800000004000000c8ffffff08000000140000000800000022737472696e67220000000017000000' +
'537061726b3a44617461547970653a4a736f6e547970650008000c000800040008000000080000001000000006000000535452494e47' +
'000016000000537061726b3a44617461547970653a53716c4e616d65000000000000040004000400000006000000526573756c7400' +
'0000000000',
'hex',
);
const placeholderSchema = {
columns: [
{
columnName: 'Result',
position: 1,
comment: '',
typeDesc: { types: [{ primitiveEntry: { type: TTypeId.STRING_TYPE } }] },
},
],
};

function directResults(arrowSchema?: Buffer): TSparkDirectResults {
return {
operationStatus: {
status: { statusCode: TStatusCode.SUCCESS_STATUS },
operationState: TOperationState.FINISHED_STATE,
},
resultSetMetadata: {
status: { statusCode: TStatusCode.SUCCESS_STATUS },
schema: placeholderSchema,
resultFormat: arrowSchema ? TSparkRowSetType.ARROW_BASED_SET : TSparkRowSetType.COLUMN_BASED_SET,
arrowSchema,
},
};
}

it('is false for DDL whose Arrow schema has no fields, without extra requests', async () => {
const context = new ClientContextStub();
const driver = sinon.spy(context.driver);
const operation = createOperationForTest({
handle: operationHandleStub({ hasResultSet: true }),
context,
directResults: directResults(createTableArrowSchema),
});

const metadata = await operation.getResultMetadata();

expect(metadata.hasResultSet).to.be.false;
expect(await operation.getSchema()).to.deep.equal(placeholderSchema);
expect(driver.getOperationStatus.called).to.be.false;
expect(driver.getResultSetMetadata.called).to.be.false;
});

it('is true for an empty SELECT that has the same Thrift schema as DDL', async () => {
const context = new ClientContextStub();
const operation = createOperationForTest({
handle: operationHandleStub({ hasResultSet: true }),
context,
directResults: directResults(emptySelectArrowSchema),
});

const metadata = await operation.getResultMetadata();

expect(metadata.hasResultSet).to.be.true;
expect(await operation.getSchema()).to.deep.equal(placeholderSchema);
});

it('is undefined for a statement without an Arrow schema', async () => {
const context = new ClientContextStub();
const operation = createOperationForTest({
handle: operationHandleStub({ hasResultSet: true }),
context,
directResults: directResults(),
});

expect((await operation.getResultMetadata()).hasResultSet).to.be.undefined;
});

it('is false when the server reports no result set, even without an Arrow schema', async () => {
const context = new ClientContextStub();
const operation = createOperationForTest({
handle: operationHandleStub({ hasResultSet: false }),
context,
directResults: directResults(),
});

expect((await operation.getResultMetadata()).hasResultSet).to.be.false;
});

it('is undefined when the Arrow schema cannot be decoded', async () => {
const context = new ClientContextStub();
const log = sinon.spy(context.logger, 'log');
const operation = createOperationForTest({
handle: operationHandleStub({ hasResultSet: true }),
context,
directResults: directResults(Buffer.from('not an arrow schema')),
});

expect((await operation.getResultMetadata()).hasResultSet).to.be.undefined;
expect(log.calledWithMatch(LogLevel.debug, 'Cannot decode the Arrow schema')).to.be.true;
});

it('is true for catalog operations, which never carry an Arrow schema', async () => {
const context = new ClientContextStub();
const operation = createOperationForTest({
handle: operationHandleStub({ hasResultSet: true, operationType: TOperationType.GET_TABLES }),
context,
directResults: directResults(),
});

expect((await operation.getResultMetadata()).hasResultSet).to.be.true;
});
});

describe('fetchChunk', () => {
it('should return immediately if operation has no results', async () => {
const context = new ClientContextStub();
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/kernel/KernelOperationBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,25 @@ describe('KernelOperationBackend — M0 datatype round-trip via napi → ArrowRe
});
}

it('reports hasResultSet=false for a zero-column (DDL) schema and true for an empty SELECT', async () => {
const ddl = new KernelOperationBackend({
statement: new StatementStub(ipcSchemaOnly(new Schema([])), []),
context: new ClientContextStub(),
});
expect((await ddl.getResultMetadata()).hasResultSet).to.equal(false);
expect(await ddl.fetchChunk({ limit: 100 })).to.deep.equal([]);

const emptySelect = new KernelOperationBackend({
statement: new StatementStub(
ipcSchemaOnly(new Schema([withTypeName(new Field('Result', new Utf8()), 'STRING')])),
[],
),
context: new ClientContextStub(),
});
expect((await emptySelect.getResultMetadata()).hasResultSet).to.equal(true);
expect(await emptySelect.fetchChunk({ limit: 100 })).to.deep.equal([]);
});

it('preserves decimal qualifiers for a schema-only result with zero scale', async () => {
const schema = new Schema([new Field('amount', new Decimal(0, 38, 128), true)]);
const backend = new KernelOperationBackend({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ async function expectEmptyPrimaryKeys(operation: IOperation): Promise<void> {
const metadata = await (operation as DBSQLOperation).getMetadata();
expect(metadata.status.statusCode).to.equal(TStatusCode.SUCCESS_STATUS);
expect(metadata.resultFormat).to.equal(TSparkRowSetType.COLUMN_BASED_SET);
expect((await operation.getResultMetadata()).hasResultSet).to.be.true;
const status = await operation.status();
expect(status.operationState).to.equal(TOperationState.FINISHED_STATE);
expect(status.status.statusCode).to.equal(TStatusCode.SUCCESS_STATUS);
Expand Down
Loading