Skip to content

Commit 0422f27

Browse files
committed
[SEA-NodeJS] Address #416 P1 review round 2: cancel-intent, statement-leak, PEM order, TLS warn, Thrift signal
databricks-sql-nodejs#416 (gopalldb P1): - P1.1: `seaCancel` no longer rolls `isCancelled` back to false when the kernel cancel RPC fails. The caller asked to cancel, so the op stays cancelled (subsequent fetches fail fast, poll-loop observers stay consistent); the RPC failure is still surfaced via the rethrow. Rolling back silently resurrected a cancelled op while the server statement might still run. Test asserts the flag stays set on failure. - P1.5: the async poll loop now best-effort `close()`s the kernel statement on every server-driven terminal error (Failed / Cancelled / Closed / Unknown / Timeout) before throwing — previously it leaked the statement handle until session close (only `fetchChunk` cleaned up). Warn-logs a close failure. - P1.3: `customCaCert` PEM check is now an ordered regex (`BEGIN…END` block) instead of two independent substring checks, so a blob containing both markers out of order (e.g. a proxy-intercept page) is rejected. - P1.4: warn when `customCaCert` is set together with `checkServerCertificate: false` — verification is fully off so the custom CA is unused; the combo is still honoured but no longer silently masks it. - P1.6: the Thrift `rowLimit`/`statementConf` ignored-option signal is now a WARN (was debug) — these materially change results (e.g. `rowLimit` not capping), so a caller on the Thrift path gets a visible warning. P1.2 (race in-flight `status()` against a cancel signal) deferred: bounded by the HTTP transport timeout today; the proper fix needs a cancel-signal promise + napi `AbortSignal`, which the binding doesn't yet expose — tracked as a follow-up. Validated: tsc/eslint/prettier clean; 243 SEA / 1162 full unit tests pass; live smoke confirms both modes execute correctly. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent c875ce8 commit 0422f27

6 files changed

Lines changed: 78 additions & 14 deletions

File tree

lib/sea/SeaAuth.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,16 +195,15 @@ export function buildSeaTlsOptions(options: ConnectionOptions): SeaTlsOptions {
195195

196196
if (customCaCert !== undefined) {
197197
if (typeof customCaCert === 'string') {
198-
// Light PEM sanity check — require both the BEGIN and END markers so a
199-
// truncated/headerless cert is rejected here rather than surfacing as an
200-
// opaque kernel TLS error. Full parsing is deferred to the kernel.
201-
if (
202-
!customCaCert.includes('-----BEGIN CERTIFICATE-----') ||
203-
!customCaCert.includes('-----END CERTIFICATE-----')
204-
) {
198+
// Light PEM sanity check — require a well-ordered BEGIN…END block so a
199+
// truncated/headerless cert (or a stray page that merely contains both
200+
// literals out of order, e.g. a proxy-intercept page) is rejected here
201+
// rather than surfacing as an opaque kernel TLS error. Ordered match, not
202+
// two independent substring checks. Full parsing is deferred to the kernel.
203+
if (!/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/.test(customCaCert)) {
205204
throw new HiveDriverError(
206205
'SEA backend: `customCaCert` string does not look like a PEM certificate ' +
207-
"(missing the '-----BEGIN CERTIFICATE-----' / '-----END CERTIFICATE-----' markers). " +
206+
"(expected a '-----BEGIN CERTIFICATE-----' '-----END CERTIFICATE-----' block). " +
208207
'Pass PEM text or a Buffer of PEM bytes.',
209208
);
210209
}

lib/sea/SeaBackend.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import IBackend from '../contracts/IBackend';
1616
import ISessionBackend from '../contracts/ISessionBackend';
1717
import IClientContext from '../contracts/IClientContext';
1818
import { ConnectionOptions, OpenSessionRequest } from '../contracts/IDBSQLClient';
19+
import { InternalConnectionOptions } from '../contracts/InternalConnectionOptions';
20+
import { LogLevel } from '../contracts/IDBSQLLogger';
1921
import HiveDriverError from '../errors/HiveDriverError';
2022
import { getSeaNative, SeaNativeBinding, SeaConnection } from './SeaNativeLoader';
2123
import { decodeNapiKernelError } from './SeaErrorMapping';
@@ -78,6 +80,22 @@ export default class SeaBackend implements IBackend {
7880
// Any non-PAT mode (or a missing/empty token) throws here, before
7981
// we ever touch the native binding.
8082
this.nativeOptions = buildSeaConnectionOptions(options);
83+
84+
// Warn on the insecure combo: a `customCaCert` paired with
85+
// `checkServerCertificate: false` is almost always a mistake — verification
86+
// is fully off, so the custom trust anchor is never used. The combo is
87+
// still honoured (kernel contract), but a secure-looking `customCaCert`
88+
// shouldn't silently mask disabled verification.
89+
const tlsOpts = options as ConnectionOptions & InternalConnectionOptions;
90+
if (tlsOpts.checkServerCertificate === false && tlsOpts.customCaCert !== undefined) {
91+
this.context
92+
.getLogger()
93+
.log(
94+
LogLevel.warn,
95+
'SEA: `customCaCert` is set but `checkServerCertificate: false` disables certificate ' +
96+
'verification entirely — the custom CA is not used. Set `checkServerCertificate: true` to use it.',
97+
);
98+
}
8199
}
82100

83101
public async openSession(request: OpenSessionRequest): Promise<ISessionBackend> {

lib/sea/SeaOperationBackend.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -468,15 +468,28 @@ export default class SeaOperationBackend implements IOperationBackend {
468468
case OperationState.Failed:
469469
// `status()` collapses Failed to the variant name only; the real
470470
// SQL-error envelope (sql_state / error_code / query_id) rides on
471-
// `awaitResult()`'s rejection — drive it to surface the typed error.
472-
// eslint-disable-next-line no-await-in-loop
473-
await this.throwAsyncError();
471+
// `awaitResult()`'s rejection — drive it to surface the typed error,
472+
// then best-effort close the leaked statement before it propagates.
473+
try {
474+
// eslint-disable-next-line no-await-in-loop
475+
await this.throwAsyncError();
476+
} catch (failErr) {
477+
// eslint-disable-next-line no-await-in-loop
478+
await this.bestEffortClose();
479+
throw failErr;
480+
}
474481
break;
475482
case OperationState.Cancelled:
483+
// eslint-disable-next-line no-await-in-loop
484+
await this.bestEffortClose();
476485
throw new OperationStateError(OperationStateErrorCode.Canceled);
477486
case OperationState.Closed:
487+
// eslint-disable-next-line no-await-in-loop
488+
await this.bestEffortClose();
478489
throw new OperationStateError(OperationStateErrorCode.Closed);
479490
default:
491+
// eslint-disable-next-line no-await-in-loop
492+
await this.bestEffortClose();
480493
throw new OperationStateError(OperationStateErrorCode.Unknown);
481494
}
482495

@@ -497,6 +510,10 @@ export default class SeaOperationBackend implements IOperationBackend {
497510
`statement may keep running until the session is closed. Cause: ${cause}`,
498511
);
499512
});
513+
// Release the statement handle too (cancel stops the work; close frees
514+
// the handle) — best-effort, mirroring the other terminal branches.
515+
// eslint-disable-next-line no-await-in-loop
516+
await this.bestEffortClose();
500517
throw new OperationStateError(OperationStateErrorCode.Timeout);
501518
}
502519

@@ -549,6 +566,26 @@ export default class SeaOperationBackend implements IOperationBackend {
549566
throw new HiveDriverError(`SEA operation ${this._id} reported Failed but produced a result.`);
550567
}
551568

569+
/**
570+
* Best-effort close of the kernel statement when the poll loop ends on a
571+
* server-driven terminal error (Failed/Cancelled/Closed/Unknown/Timeout).
572+
* Without it the kernel-side statement handle leaks until session close (the
573+
* poll loop, unlike `fetchChunk`, otherwise just throws). Never masks the
574+
* original error; warn-logs a close failure so the leak is diagnosable.
575+
*/
576+
private async bestEffortClose(): Promise<void> {
577+
await seaClose(this.lifecycle, this.lifecycleHandle, this.context, this._id).catch((closeErr) => {
578+
const cause = closeErr instanceof Error ? closeErr.message : String(closeErr);
579+
this.context
580+
.getLogger()
581+
.log(
582+
LogLevel.warn,
583+
`SEA poll-loop cleanup: close() failed for operation ${this._id}; the server-side ` +
584+
`statement may leak until the session is closed. Cause: ${cause}`,
585+
);
586+
});
587+
}
588+
552589
/**
553590
* Resolve (and memoise) the fetch handle: `awaitResult()`'s `AsyncResultHandle`
554591
* on the query path, or the already-terminal `Statement` on the metadata path.

lib/sea/SeaOperationLifecycle.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,17 @@ export async function seaCancel(
131131

132132
context.getLogger().log(LogLevel.debug, `Cancelling SEA operation with id: ${operationId}`);
133133

134+
// Set the intent BEFORE the RPC and keep it set even if the cancel RPC
135+
// fails: the caller asked to cancel, so the operation must stay cancelled
136+
// (subsequent fetches fail fast, and any poll-loop observer that already saw
137+
// the flag stays consistent). The RPC failure is surfaced via the rethrow —
138+
// we do NOT roll the flag back, which would silently resurrect a cancelled
139+
// operation while the kernel-side statement may still be running.
134140
state.isCancelled = true;
135141

136142
try {
137143
await statement.cancel();
138144
} catch (err) {
139-
state.isCancelled = false;
140145
rethrowKernelError(err);
141146
}
142147

lib/thrift-backend/ThriftSessionBackend.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,10 @@ export default class ThriftSessionBackend implements ISessionBackend {
177177
this.context
178178
.getLogger()
179179
.log(
180-
LogLevel.debug,
180+
LogLevel.warn,
181181
'ThriftSessionBackend.executeStatement: rowLimit / statementConf are kernel-backend (useSEA) ' +
182-
'options and are ignored on the Thrift path.',
182+
'options with no Thrift wire equivalent — they are IGNORED on the Thrift path (e.g. rowLimit ' +
183+
'will not cap the result set). Use the kernel backend (useSEA) to honour them.',
183184
);
184185
}
185186

tests/unit/sea/operation-lifecycle.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ describe('SeaOperationLifecycle (helpers)', () => {
166166
}
167167
expect(thrown).to.be.instanceOf(HiveDriverError);
168168
expect((thrown as Error).message).to.contain('statement already closed');
169+
// The caller asked to cancel: a failed cancel RPC must NOT roll the intent
170+
// back (doing so would silently resurrect a cancelled op while the
171+
// server-side statement may still be running).
172+
expect(state.isCancelled).to.equal(true);
169173
});
170174

171175
it('logs a debug message tagged with the operation id', async () => {

0 commit comments

Comments
 (0)