Skip to content

Commit 4584657

Browse files
committed
[SEA-NodeJS] Address #413 review: TIMESTAMP_LTZ wire type + Int64 queryTimeout coercion
Code-review #413 (gopalldb). Two P1s: - TIMESTAMP_LTZ was sent verbatim on the wire, but Spark has no distinct TIMESTAMP_LTZ type (TIMESTAMP already carries LTZ semantics) — so a Thrift caller got an opaque server bind error, and the enum comment falsely claimed NTZ/LTZ "degrade to a plain TIMESTAMP bind" (there was no such logic). `toSparkParameter` now maps TIMESTAMP_LTZ → `TIMESTAMP` (valid on both Thrift and kernel); TIMESTAMP_NTZ stays native (a real Spark type). Comment corrected. Added DBSQLParameter tests for both wire types (the Thrift behaviour the review flagged as untested) and updated the kernel positional-params test. - queryTimeout (`number | bigint | Int64`) was coerced with `Number(...)`, which yields NaN for an Int64 (node-int64 has no valueOf) → the client-side deadline was silently disabled for Int64 inputs. Now uses `numberToInt64(...).toNumber()`, matching the Thrift backend. Added a regression test that an `Int64(1)` queryTimeout actually fires the deadline (OperationStateError(Timeout)) rather than polling forever. (P1 "queryTimeout silently dropped on submit" and the unbounded-poll note were already resolved earlier by the client-side deadline fix; doc comment updated to match. P2 polarity/Date-NTZ items noted for the public-surface follow-up.) Validated live: NTZ binds natively and LTZ binds as TIMESTAMP on the kernel path. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent 38eea67 commit 4584657

5 files changed

Lines changed: 71 additions & 15 deletions

File tree

lib/DBSQLParameter.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ export enum DBSQLParameterType {
88
STRING = 'STRING',
99
DATE = 'DATE',
1010
TIMESTAMP = 'TIMESTAMP',
11-
// Timezone-explicit timestamp variants. A bare `Date` value defaults to
12-
// `TIMESTAMP`; set one of these explicitly to bind a TIMESTAMP_NTZ (no
13-
// timezone, wall-clock) or TIMESTAMP_LTZ (local timezone) parameter. These
14-
// are SEA-path types the kernel param codec accepts; the Thrift wire only
15-
// has `TIMESTAMP`, so on the Thrift backend they degrade to a plain
16-
// TIMESTAMP bind.
11+
// `TIMESTAMP_NTZ` binds a timezone-free (wall-clock) timestamp. It is a real
12+
// Spark type, bound natively on both the Thrift and kernel backends (requires
13+
// a server that supports TIMESTAMP_NTZ; Spark 3.4+ / recent DBR).
1714
TIMESTAMP_NTZ = 'TIMESTAMP_NTZ',
15+
// `TIMESTAMP_LTZ` is an alias for `TIMESTAMP`: Spark has no distinct
16+
// TIMESTAMP_LTZ type — `TIMESTAMP` already carries local/instant (LTZ)
17+
// semantics. `toSparkParameter` therefore binds it as `TIMESTAMP` on the wire
18+
// (valid on both backends); it exists only as a self-documenting alias.
1819
TIMESTAMP_LTZ = 'TIMESTAMP_LTZ',
1920
FLOAT = 'FLOAT',
2021
DECIMAL = 'DECIMAL',
@@ -58,10 +59,16 @@ export class DBSQLParameter {
5859
return new TSparkParameter({ name }); // for NULL neither `type` nor `value` should be set
5960
}
6061

62+
// Map timezone-explicit timestamp aliases to their Spark wire type. Spark
63+
// has no distinct TIMESTAMP_LTZ type (TIMESTAMP carries LTZ semantics), so
64+
// bind it as TIMESTAMP — valid on both the Thrift and kernel backends.
65+
// TIMESTAMP_NTZ is a real Spark type and is bound natively.
66+
const wireType = this.type === DBSQLParameterType.TIMESTAMP_LTZ ? DBSQLParameterType.TIMESTAMP : this.type;
67+
6168
if (typeof this.value === 'boolean') {
6269
return new TSparkParameter({
6370
name,
64-
type: this.type ?? DBSQLParameterType.BOOLEAN,
71+
type: wireType ?? DBSQLParameterType.BOOLEAN,
6572
value: new TSparkParameterValue({
6673
stringValue: this.value ? 'TRUE' : 'FALSE',
6774
}),
@@ -71,7 +78,7 @@ export class DBSQLParameter {
7178
if (typeof this.value === 'number') {
7279
return new TSparkParameter({
7380
name,
74-
type: this.type ?? (Number.isInteger(this.value) ? DBSQLParameterType.INTEGER : DBSQLParameterType.DOUBLE),
81+
type: wireType ?? (Number.isInteger(this.value) ? DBSQLParameterType.INTEGER : DBSQLParameterType.DOUBLE),
7582
value: new TSparkParameterValue({
7683
stringValue: Number(this.value).toString(),
7784
}),
@@ -81,7 +88,7 @@ export class DBSQLParameter {
8188
if (this.value instanceof Int64 || typeof this.value === 'bigint') {
8289
return new TSparkParameter({
8390
name,
84-
type: this.type ?? DBSQLParameterType.BIGINT,
91+
type: wireType ?? DBSQLParameterType.BIGINT,
8592
value: new TSparkParameterValue({
8693
stringValue: this.value.toString(),
8794
}),
@@ -91,7 +98,7 @@ export class DBSQLParameter {
9198
if (this.value instanceof Date) {
9299
return new TSparkParameter({
93100
name,
94-
type: this.type ?? DBSQLParameterType.TIMESTAMP,
101+
type: wireType ?? DBSQLParameterType.TIMESTAMP,
95102
value: new TSparkParameterValue({
96103
stringValue: this.value.toISOString(),
97104
}),
@@ -100,7 +107,7 @@ export class DBSQLParameter {
100107

101108
return new TSparkParameter({
102109
name,
103-
type: this.type ?? DBSQLParameterType.STRING,
110+
type: wireType ?? DBSQLParameterType.STRING,
104111
value: new TSparkParameterValue({
105112
stringValue: this.value,
106113
}),

lib/sea/SeaSessionBackend.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import ParameterError from '../errors/ParameterError';
3535
import { LogLevel } from '../contracts/IDBSQLLogger';
3636
import { SeaConnection, SeaNativeExecuteOptions, SeaStatement } from './SeaNativeLoader';
3737
import { decodeNapiKernelError } from './SeaErrorMapping';
38+
import { numberToInt64 } from '../thrift-backend/ThriftSessionBackend';
3839
import SeaOperationBackend from './SeaOperationBackend';
3940
import { buildSeaPositionalParams, buildSeaNamedParams } from './SeaPositionalParams';
4041
import { seaServerInfoValue } from './SeaServerInfo';
@@ -121,7 +122,9 @@ export default class SeaSessionBackend implements ISessionBackend {
121122
* Per-statement options forwarded to the kernel `ExecuteOptions`:
122123
* - `ordinalParameters` / `namedParameters` → bound params (mutually
123124
* exclusive — the kernel binds one placeholder style per statement);
124-
* - `queryTimeout` → `queryTimeoutSecs` (SEA server wait timeout);
125+
* - `queryTimeout` → enforced client-side by the operation backend's poll
126+
* deadline (the kernel ignores `queryTimeoutSecs` on the async submit
127+
* path), NOT forwarded to the napi options;
125128
* - `rowLimit` → `rowLimit` (SEA-only server-side row cap);
126129
* - `queryTags` → serialised into the conf overlay's reserved
127130
* `query_tags` key (the same wire shape Thrift's `serializeQueryTags`
@@ -176,7 +179,9 @@ export default class SeaSessionBackend implements ISessionBackend {
176179
return new SeaOperationBackend({
177180
asyncStatement: asyncStatement!,
178181
context: this.context,
179-
queryTimeoutSecs: options.queryTimeout !== undefined ? Number(options.queryTimeout) : undefined,
182+
// `queryTimeout` is typed `number | bigint | Int64`; `numberToInt64(...).toNumber()`
183+
// coerces all three (a bare `Number(int64)` yields NaN — node-int64 has no valueOf).
184+
queryTimeoutSecs: options.queryTimeout !== undefined ? numberToInt64(options.queryTimeout).toNumber() : undefined,
180185
});
181186
}
182187

tests/unit/DBSQLParameter.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,27 @@ describe('DBSQLParameter', () => {
101101
expect(dbsqlParam.toSparkParameter()).to.deep.equal(expectedParam);
102102
}
103103
});
104+
105+
it('maps timezone-explicit timestamp types to valid Spark wire types', () => {
106+
// TIMESTAMP_NTZ is a real Spark type → bound verbatim.
107+
expect(
108+
new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP_NTZ, value: '2024-01-15 10:30:00' }).toSparkParameter(),
109+
).to.deep.equal(
110+
new TSparkParameter({
111+
type: DBSQLParameterType.TIMESTAMP_NTZ,
112+
value: new TSparkParameterValue({ stringValue: '2024-01-15 10:30:00' }),
113+
}),
114+
);
115+
// TIMESTAMP_LTZ has no distinct Spark type → bound as TIMESTAMP (valid on
116+
// both Thrift and kernel; the old verbatim 'TIMESTAMP_LTZ' was rejected by
117+
// the Thrift server).
118+
expect(
119+
new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP_LTZ, value: '2024-01-15 10:30:00' }).toSparkParameter(),
120+
).to.deep.equal(
121+
new TSparkParameter({
122+
type: DBSQLParameterType.TIMESTAMP,
123+
value: new TSparkParameterValue({ stringValue: '2024-01-15 10:30:00' }),
124+
}),
125+
);
126+
});
104127
});

tests/unit/sea/execution.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import { expect } from 'chai';
1616
import sinon from 'sinon';
17+
import Int64 from 'node-int64';
1718
import SeaBackend from '../../../lib/sea/SeaBackend';
1819
import SeaSessionBackend from '../../../lib/sea/SeaSessionBackend';
1920
import SeaOperationBackend from '../../../lib/sea/SeaOperationBackend';
@@ -526,6 +527,26 @@ describe('SeaSessionBackend', () => {
526527
expect((connection.lastOptions as { queryTimeoutSecs?: number } | undefined)?.queryTimeoutSecs).to.equal(undefined);
527528
});
528529

530+
it('coerces an Int64 queryTimeout into the client-side deadline (not NaN)', async function int64Timeout() {
531+
// Regression: `Number(new Int64(...))` yields NaN (node-int64 has no valueOf),
532+
// which would silently disable the deadline. The backend must coerce via
533+
// numberToInt64(...).toNumber() so an Int64 queryTimeout still bounds the poll.
534+
// eslint-disable-next-line no-invalid-this
535+
this.timeout(5000);
536+
const connection = new FakeNativeConnection();
537+
connection.submitStatusValue = 'Running'; // never reaches a terminal state
538+
const session = makeSession(connection);
539+
const op = await session.executeStatement('SELECT 1', { queryTimeout: new Int64(1) });
540+
let thrown: unknown;
541+
try {
542+
await op.waitUntilReady();
543+
} catch (err) {
544+
thrown = err;
545+
}
546+
expect(thrown, 'Int64(1) timeout must fire — NaN would poll forever').to.be.instanceOf(OperationStateError);
547+
expect((thrown as OperationStateError).errorCode).to.equal(OperationStateErrorCode.Timeout);
548+
});
549+
529550
it('executeStatement forwards rowLimit', async () => {
530551
const connection = new FakeNativeConnection();
531552
const session = makeSession(connection);

tests/unit/sea/positionalParams.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,15 @@ describe('SeaPositionalParams.buildSeaPositionalParams', () => {
9191
]);
9292
});
9393

94-
it('honours explicit TIMESTAMP_NTZ / TIMESTAMP_LTZ types (kernel param codec)', () => {
94+
it('binds TIMESTAMP_NTZ natively and TIMESTAMP_LTZ as TIMESTAMP (Spark has no distinct LTZ type)', () => {
9595
expect(
9696
buildSeaPositionalParams([
9797
new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP_NTZ, value: '2024-01-15 10:30:00' }),
9898
new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP_LTZ, value: '2024-01-15 10:30:00' }),
9999
]),
100100
).to.deep.equal([
101101
{ sqlType: 'TIMESTAMP_NTZ', value: '2024-01-15 10:30:00' },
102-
{ sqlType: 'TIMESTAMP_LTZ', value: '2024-01-15 10:30:00' },
102+
{ sqlType: 'TIMESTAMP', value: '2024-01-15 10:30:00' },
103103
]);
104104
});
105105
});

0 commit comments

Comments
 (0)