Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## Unreleased

- Reduce result chunking and `fetchAll()` overhead for large query results.
- Support Volume file transfers on the kernel backend when `stagingAllowedLocalPath` is supplied. Symlink targets must remain within configured allowed roots; in-root symlinks are permitted.

- Thrift CloudFetch downloads are now bounded end to end by `retriesTimeout` (default 15 minutes), including every attempt and retry backoff. Previously a stalled download, where the response headers or body never arrived, blocked the fetch forever. A download still running when the deadline expires is aborted and fails with `RetryError` (`TIMEOUT_EXCEEDED`, message `CloudFetch result download failed: Retry timeout exceeded`). (#520)
- Thrift connections made through `proxy` now apply `checkServerCertificate`, `customCaCert` and `clientCert`/`clientKey`. Previously the proxied path ignored them and verified against Node's process-wide trust store, so a certificate outside the configured trust set could be accepted. **Compatibility:** proxied connections now verify unless `checkServerCertificate: false` is set; as on direct connections, `NODE_TLS_REJECT_UNAUTHORIZED=0` no longer turns verification off. For an `https` proxy these options also apply to the proxy's own certificate. (#485)
- Honor the client `useCloudFetch: false` default on the kernel backend so complete results can be fetched without cloud-storage downloads. (#574)
Expand Down
11 changes: 8 additions & 3 deletions CONNECTION_PARAMETERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ telemetry implementation, so only the options explicitly forwarded by
| `maxRows` | `number \| bigint \| Int64 \| null` | ✅ | ❌ | `100000` / — | Thrift uses it for the initial Direct Results row count; `null` disables Direct Results. Kernel does not retain or forward the supplied value. |
| `useCloudFetch` | `boolean` | ✅ | ❌ | `true` / — | Kernel logs and ignores this per-statement hint; Kernel result fetching owns its CloudFetch behavior. |
| `useLZ4Compression` | `boolean` | ✅ | ❌ | `true` / — | Thrift uses it when supported and when the result is not CloudFetch. Kernel owns and auto-detects result compression. |
| `stagingAllowedLocalPath` | `string \| string[]` | ✅ | ❌ | — | Local allowlist for Thrift volume/staging operations. Kernel volume operations are unsupported. |
| `stagingAllowedLocalPath` | `string \| string[]` | ✅ | ✅ | — | Local allowlist for Volume operations. Kernel also requires resolved symlink targets within allowed roots; links within those roots are permitted. |
| `namedParameters` | `Record<string, DBSQLParameter \| DBSQLParameterValue>` | ✅ | ✅ | — | Named SQL parameters. Cannot be combined with non-empty `ordinalParameters`. Thrift requires a protocol that supports parameterized queries. |
| `ordinalParameters` | `Array<DBSQLParameter \| DBSQLParameterValue>` | ✅ | ✅ | — | Positional SQL parameters. Cannot be combined with non-empty `namedParameters`. |
| `queryTags` | `Record<string, string \| null \| undefined>` | ✅ | ✅ | — | Serialized into the per-statement `query_tags` conf overlay on both. |
Expand Down Expand Up @@ -228,11 +228,16 @@ session parameter surface.

Thrift-only capabilities include custom/token-provider authentication, custom
OAuth persistence, SOCKS proxies, authenticated/driver-buffer telemetry knobs,
query timeout, Direct Results `maxRows`, per-statement CloudFetch/LZ4 hints, and
volume operations.
query timeout, Direct Results `maxRows`, and per-statement CloudFetch/LZ4 hints.

Kernel-only capabilities include connection-pool sizing, proxy bypass hosts,
independent TLS hostname verification, optional U2M disk caching, and
per-statement `rowLimit`/`statementConf`.

The Kernel path remains internal and unstable; its behavior may change.

With the kernel backend, supplying `stagingAllowedLocalPath` completes Volume
transfers before `executeStatement` resolves and consumes instruction rows.
Omitting the option returns raw instructions without file effects. Unlike the
Thrift backend’s lexical path check, the kernel also rejects symlink targets
outside the configured roots while permitting symlinks within them.
2 changes: 1 addition & 1 deletion KERNEL_REV
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0b5d0edfed32c0745d5abc2468fc3070e2eb2a17
855847bdca71e07285cfc60332fb2f33f8e47786
15 changes: 12 additions & 3 deletions lib/DBSQLSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,20 @@ export default class DBSQLSession implements IDBSQLSession {
// operation, everything will work as usual. In a case of staging operation, it will be processed like any
// other query - it will be possible to get data from it as usual, or use other operation methods.
if (options.stagingAllowedLocalPath !== undefined) {
const allowedLocalPath = Array.isArray(options.stagingAllowedLocalPath)
? options.stagingAllowedLocalPath
: [options.stagingAllowedLocalPath];
if (opBackend.completeStagingOperation) {
try {
await this.runBackend(() => opBackend.completeStagingOperation!(allowedLocalPath));
return operation;
} catch (error) {
await operation.close().catch(() => {});
throw error;
}
}
const metadata = await operation.getResultMetadata();
if (metadata.isStagingOperation) {
const allowedLocalPath = Array.isArray(options.stagingAllowedLocalPath)
? options.stagingAllowedLocalPath
: [options.stagingAllowedLocalPath];
return this.handleStagingOperation(operation, allowedLocalPath, metadata);
}
}
Expand Down
3 changes: 2 additions & 1 deletion lib/contracts/IDBSQLSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export type ExecuteStatementOptions = {
* `false`/unset (default) runs the blocking direct-results path (faster,
* cancellable mid-compute); `true` submits and polls (returns a pending
* handle before completion). Default is sync, matching the python
* connector's `cursor.execute()`.
* connector's `cursor.execute()`. With `stagingAllowedLocalPath` configured,
* execution waits for staging completion regardless of this option.
*/
runAsync?: boolean;
maxRows?: number | bigint | Int64 | null;
Expand Down
2 changes: 2 additions & 0 deletions lib/contracts/IOperationBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export default interface IOperationBackend {
*/
getCachedResultMetadata?(): ResultMetadata | undefined;

completeStagingOperation?(allowedPaths: string[]): Promise<void>;

/** Cancel the operation. */
cancel(): Promise<Status>;

Expand Down
24 changes: 23 additions & 1 deletion lib/kernel/KernelErrorMapping.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { constants } from 'node:os';
import { getSystemErrorName } from 'node:util';
import HiveDriverError from '../errors/HiveDriverError';
import AuthenticationError from '../errors/AuthenticationError';
import OperationStateError, { OperationStateErrorCode } from '../errors/OperationStateError';
Expand Down Expand Up @@ -311,7 +313,27 @@ export function decodeNapiKernelError(err: unknown): Error {
const msg = envelope.message as string;
const sqlState = typeof envelope.sqlState === 'string' ? envelope.sqlState : undefined;

const jsErr = mapKernelErrorToJsError({ code, message: msg, sqlstate: sqlState });
let jsErr = mapKernelErrorToJsError({ code, message: msg, sqlstate: sqlState });
if (typeof envelope.localPath === 'string' && typeof envelope.localErrorKind === 'string') {
const kindCodes: Record<string, string> = {
NotFound: 'ENOENT',
PermissionDenied: 'EACCES',
AlreadyExists: 'EEXIST',
NotADirectory: 'ENOTDIR',
IsADirectory: 'EISDIR',
DirectoryNotEmpty: 'ENOTEMPTY',
};
const raw = typeof envelope.localOsError === 'number' ? envelope.localOsError : undefined;
const localCode =
process.platform !== 'win32' && raw

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The new local-error branch has two code paths: the POSIX getSystemErrorName(-raw) path, and the kindCodes / 'EIO' fallback, which runs on Windows, when localOsError is missing or 0, or when the kind is unknown. The one new test (error-mapping.test.ts, localOsError: 2 on Linux) only covers the first. Nothing tests that a missing or zero localOsError maps by kind (e.g. PermissionDenied → EACCES), or that an unknown kind gives EIO with a sensible negative errno. This branch also swaps the typed driver error for a plain Error. A test that pins the result (no sqlState, but kernelMetadata and cause still attached) would protect that contract.

? getSystemErrorName(-Math.abs(raw))
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
: kindCodes[envelope.localErrorKind] ?? 'EIO';
jsErr = Object.assign(new Error(msg), {
code: localCode,
errno: -((constants.errno as Record<string, number>)[localCode] ?? Math.abs(raw ?? constants.errno.EIO)),
path: envelope.localPath,
});
}

const meta = buildKernelMetadata(envelope);
// Skip the namespace attachment entirely when no fields validated
Expand Down
39 changes: 35 additions & 4 deletions lib/kernel/KernelOperationBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,14 @@ import { LogLevel } from '../contracts/IDBSQLLogger';
import { RowMode } from '../contracts/IDBSQLSession';
import Status from '../dto/Status';
import HiveDriverError from '../errors/HiveDriverError';
import StagingError from '../errors/StagingError';
import ParameterError from '../errors/ParameterError';
import OperationStateError, { OperationStateErrorCode } from '../errors/OperationStateError';
import ArrowResultConverter from '../result/ArrowResultConverter';
import ResultSlicer from '../result/ResultSlicer';
import KernelResultsProvider from './KernelResultsProvider';
import { arrowSchemaToThriftSchema, decodeIpcSchema, patchIpcBytes } from './KernelArrowIpc';
import { decodeNapiKernelError } from './KernelErrorMapping';
import { decodeNapiKernelError, ErrorWithSqlState } from './KernelErrorMapping';
import {
KernelStatement,
KernelNativeAsyncStatement,
Expand Down Expand Up @@ -84,7 +86,10 @@ export type KernelOperationStatement = KernelStatementHandle & Partial<KernelSta
* (`KernelResultsProvider` → `ArrowResultConverter` → `ResultSlicer`) consumes
* either interchangeably.
*/
type KernelFetchHandle = Pick<KernelStatement, 'fetchNextBatch' | 'schema'>;
type KernelFetchHandle = Pick<
KernelStatement,
'fetchNextBatch' | 'schema' | 'isVolumeOperation' | 'completeVolumeOperation'
>;

/**
* The rich operation-status surface the kernel exposes on a terminal sync
Expand Down Expand Up @@ -220,6 +225,8 @@ export default class KernelOperationBackend implements IOperationBackend {
// already-terminal statement. Drives both fetch and result-metadata.
private fetchHandlePromise?: Promise<KernelFetchHandle>;

private volumeCompleted = false;

// Async path only: set once the kernel reported the statement Succeeded (a
// status poll, or `awaitResult()` resolving). The kernel exposes no
// synchronous terminal-state accessor, so the observed success is retained.
Expand Down Expand Up @@ -256,7 +263,7 @@ export default class KernelOperationBackend implements IOperationBackend {
// drop-guard fires whenever this handle is eventually GC'd.
this.lifecycleHandle = cancellableExecution
? {
cancel: () => cancellableExecution.cancel(),
cancel: () => (this.blockingStatement ? this.blockingStatement.cancel() : cancellableExecution.cancel()),
close: () => (this.blockingStatement ? this.blockingStatement.close() : cancellableExecution.cancel()),
}
: ((asyncStatement ?? statement) as KernelStatementHandle);
Expand Down Expand Up @@ -304,6 +311,28 @@ export default class KernelOperationBackend implements IOperationBackend {
// Fetch / metadata (owned by the kernel-results pipeline).
// ---------------------------------------------------------------------------

public async completeStagingOperation(allowedPaths: string[]): Promise<void> {
failIfNotActive(this.lifecycle);
const handle = await this.getFetchHandle();
try {
const outcome = await handle.completeVolumeOperation(allowedPaths, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — Nothing checks that the loaded native binding actually has completeVolumeOperation. The PR description says published native 1.4.0 doesn't have it. On that binding, any kernel executeStatement that passes stagingAllowedLocalPath will reject with TypeError: handle.completeVolumeOperation is not a function. That includes plain SELECTs, because DBSQLSession now calls completeStagingOperation for every statement with the option set. decodeNapiKernelError passes the TypeError through unchanged. Before this PR the same call logged a warning and continued.

If the binding can't be pinned to a compatible release when this merges, add a feature-detect: when typeof handle.completeVolumeOperation !== 'function', throw a clear StagingError/HiveDriverError stating that the native binding doesn't support Volume operations. That would replace the opaque TypeError.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed: compatible published native packages are a merge prerequisite, including ordinary statements with stagingAllowedLocalPath. Source KERNEL_REV does not update installed platform dependencies. The matching release must supply completion, all platform pins/locks must advance, and clean installed queries plus Volume workflows must pass before merge. The accessible proxies currently expose 1.4.0, whose API is incompatible. Keeping the gate open; no old-native fallback or unavailable version is being introduced.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming. I agree that pinning a compatible published native release is the right fix, and that it has to land before merge. Until then, the code at this line still calls handle.completeVolumeOperation without checking it exists, so on 1.4.0 it would still fail with the opaque TypeError. I'm leaving this thread open as the merge gate. It can be resolved once the platform pins and lockfiles move to a release that includes completeVolumeOperation. If that release won't be ready by merge time, the typeof check plus a clear StagingError is still a cheap safeguard.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The release plan is coordinated native/connector version updates. Published 1.4.0 remains unsupported for these calls: before connector release, compatible platform pins and locks plus clean installed ordinary-query and Volume validation are required. Source validation selects kernel 855847bd with completeVolumeOperation. We are continuing review of that source pair while keeping package integration as a release prerequisite; no fallback, invented version or completed package proof is claimed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for laying out the release plan. Your reply doesn't challenge the finding. The code at line 318 still calls handle.completeVolumeOperation without checking that it exists, and the installed pins still resolve to 1.4.0. My last reply already said this thread stays open as the merge gate. It can be resolved once the platform pins and lockfiles move to a native release that has completeVolumeOperation, or once a typeof guard that throws a clear StagingError lands. Until one of those is pushed, I'm keeping it open.

if (outcome === 'needs_input_stream') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — No test covers the needs_input_stream outcome. The suite only exercises completed, not_volume and thrown errors. This branch throws inside the try, gets passed through decodeNapiKernelError, and must still come out as a StagingError. A single test case pinning that would stop a later refactor of the catch-side classification from silently turning it into a different error class.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a useful optional branch test, but source inspection does not establish a classification defect: the needs_input_stream branch creates a StagingError, and decodeNapiKernelError returns a nonsentinel Error unchanged (KernelErrorMapping.ts:270–274). The class therefore survives this catch. Keeping the existing focused tests and recording the uncovered branch; no behavior change is needed for this finding.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that today's code keeps the class: decodeNapiKernelError returns a non-sentinel Error unchanged, so the StagingError gets through. I wasn't claiming a current defect, though. This finding is only about missing test coverage. Nothing pins that pass-through, so if someone later changes the catch-side mapping (for example, wrapping or reclassifying every non-sentinel error), the needs_input_stream path could start returning a different error class and no test would fail. The fix is one test that mocks completeVolumeOperation to resolve 'needs_input_stream' and asserts StagingError. Since there's still no test, I'm leaving this open as a low-priority item for a human to decide.

throw new StagingError('Volume PUT requires a local file path');
}
this.volumeCompleted = outcome === 'completed';
} catch (err) {
const mapped = decodeNapiKernelError(err);
const metadata = (mapped as ErrorWithSqlState).kernelMetadata;
if (
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
(mapped instanceof ParameterError && !metadata?.httpStatus) ||
metadata?.errorCode === 'VOLUME_STORAGE_HTTP_ERROR'
) {
throw new StagingError(mapped.message);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — When a transfer fails with a ParameterError or an error that has kernelMetadata.httpStatus, this code rethrows it as new StagingError(mapped.message). That has two problems:

  1. Kernel diagnostics are lost. The new error has no sqlState, kernelMetadata (HTTP status, queryId, retryable) or cause. The thrown error is the only failure signal callers get, and nothing else exposes these fields.
  2. The storage error's message reaches the caller unchanged. Presigned Volume URLs carry credentials in the query string. I could not check the kernel PR (Add disableRowMaterialization option to skip per-cell decode #465) to confirm whether its storage HTTP error messages include the request URL. The Thrift path avoids this by building its own message (HTTP error ${response.status} ${response.statusText}, lib/DBSQLSession.ts handleStagingGet/Put/Remove).

Possible fix: for HTTP failures, build the message from kernelMetadata.httpStatus instead of the kernel's text. In either case, copy kernelMetadata/sqlState/cause onto the StagingError, or set the mapped error as its cause.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked against 8e93f23a and native 08a92137. The storage URL-exposure premise does not hold for these sources: non-success HTTP messages contain only the status, and reqwest transport/body failures use without_url() before formatting. No raw presigned URL is passed through this native path.

Preserving additional diagnostics on legacy StagingError remains the optional enhancement identified in the earlier same-source follow-up; it is not a new failure of the staging contract. No message rewrite or new diagnostic layer is needed for this finding.

The portable local-error branch now has four cases in the existing test: raw errno, absent/zero errno with PermissionDenied→EACCES, and unknown kind→EIO. They check negative errno, path, metadata/cause and absent SQLSTATE. The four cases, broader affected tests and typecheck pass.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking the native source at 08a92137. If non-success messages carry only the status and reqwest errors go through without_url(), then the kernel doesn't leak today. I can't confirm that from here, though, because the native code isn't in this repo. Line 329 is unchanged and still passes the kernel's message straight through, so this path's safety depends on how a separate codebase formats its messages. .claude/knowledge/driver-pitfalls.md is explicit: "Never copy a network error's message into an error the caller sees" (#567). The Thrift handlers follow that by building HTTP error ${status}. Building the message from kernelMetadata.httpStatus here would be a one-line change, and it would keep this path safe if the kernel's formatting changes later. Separately, the StagingError still has no cause or kernelMetadata, and that was the first point in the finding. Those two fixes make one small change, so I'm leaving this thread open for a maintainer to decide whether to accept the reliance on the native code.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — When a transfer fails with ParameterError or an HTTP status, the error is replaced by new StagingError(mapped.message). The new error has no kernelMetadata (including httpStatus and queryId), no sqlState and no cause, even though decodeNapiKernelError just attached them.

That means a caller can't tell a storage 403 from a 404 or other failure except by parsing the message. It also can't correlate the failure with a query id. The local-filesystem branch keeps that metadata, so transfer failures end up less diagnosable than the other error classes this PR separates out.

Fix: copy the metadata onto the wrapper, e.g. Object.assign(new StagingError(mapped.message), { kernelMetadata: mapped.kernelMetadata }), or set cause: mapped. The class change still happens, but the diagnostics survive.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8f800ba narrows conversion to the tagged VOLUME_STORAGE_HTTP_ERROR case and local ParameterError without HTTP metadata. Deferred instruction-fetch 401 and 400 now retain their mapped error identity, SQLSTATE and metadata; the existing backend fixture asserts those controls. Extra structured metadata on legacy storage StagingError remains an optional API enhancement: its existing Thrift surface is the staging class/message, which this adapter preserves.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Narrowing the conversion in 8f800ba fixes part of this: instruction-fetch 400 and 401 errors now keep their class, SQLSTATE and metadata. But line 330 still throws new StagingError(mapped.message) for the VOLUME_STORAGE_HTTP_ERROR path. Those are exactly the storage failures where httpStatus and queryId matter most, so callers still can't tell a 403 from a 404 without parsing the message. Matching the legacy Thrift class and message makes sense, but adding { cause: mapped } (or copying kernelMetadata onto the wrapper) keeps that and only adds information. It's a small additive change, so I'd still like to see it here rather than deferred. Severity stays low.

}
throw mapped;
}
}

public async fetchChunk({
limit,
disableBuffering,
Expand All @@ -316,6 +345,7 @@ export default class KernelOperationBackend implements IOperationBackend {
// Cancel-mid-fetch propagation: if cancel() has flipped the
// lifecycle flag, fail locally without a wire round-trip.
failIfNotActive(this.lifecycle);
if (this.volumeCompleted) return [];
// Cooperative cancel (parity with ThriftOperationBackend): the facade
// supplies `isClosed` and re-checks `failIfClosed()` after we return, so
// bailing with `[]` at a yield point is the contract-correct way for a
Expand Down Expand Up @@ -356,6 +386,7 @@ export default class KernelOperationBackend implements IOperationBackend {

public async hasMore(): Promise<boolean> {
failIfNotActive(this.lifecycle);
if (this.volumeCompleted) return false;
const slicer = await this.getResultSlicer();
return slicer.hasMore();
}
Expand Down Expand Up @@ -394,7 +425,7 @@ export default class KernelOperationBackend implements IOperationBackend {
// `arrowSchema` doesn't hit apache-arrow@13's `Unrecognized type
// "Duration" (18)`. Matches what the per-batch fetch path already does.
arrowSchema: patchIpcBytes(arrowSchemaIpc.ipcBytes),
isStagingOperation: false,
isStagingOperation: Boolean(handle.isVolumeOperation),
};
this.metadata = meta;
return meta;
Expand Down
18 changes: 5 additions & 13 deletions lib/kernel/KernelSessionBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export default class KernelSessionBackend implements ISessionBackend {
* Still rejected (genuinely unsupported on kernel, rather than silently
* dropped): `useCloudFetch` (governed by the kernel `ResultConfig`, not a
* per-statement knob), `useLZ4Compression` (kernel owns result compression),
* and `stagingAllowedLocalPath` (volume operations). `maxRows` is applied by
* while `stagingAllowedLocalPath` is applied by the facade. `maxRows` is applied by
* the facade at fetch time, so it is intentionally not handled here.
*/
public async executeStatement(statement: string, options: ExecuteStatementOptions): Promise<IOperationBackend> {
Expand All @@ -160,8 +160,7 @@ export default class KernelSessionBackend implements ISessionBackend {
// Per-statement options the kernel backend doesn't honour are treated as
// NO-OPs (logged at warn), not errors — so call sites written for the Thrift
// backend are drop-in on the kernel path. These are perf/format hints the
// kernel governs internally (useCloudFetch / useLZ4Compression) or a feature
// it doesn't expose yet (staging); ignoring them cannot change query results.
// kernel governs internally (useCloudFetch / useLZ4Compression).
// NB: parameter binding (compound/BINARY) is deliberately NOT no-op'd — a
// dropped param would silently change results, so it still throws.
if (options.useCloudFetch !== undefined) {
Expand All @@ -180,15 +179,6 @@ export default class KernelSessionBackend implements ISessionBackend {
'kernel executeStatement: ignoring per-statement `useLZ4Compression` — result compression is governed by the kernel (no-op on kernel).',
);
}
if (options.stagingAllowedLocalPath !== undefined) {
this.context
.getLogger()
.log(
LogLevel.warn,
'kernel executeStatement: ignoring `stagingAllowedLocalPath` — volume/staging operations are not yet supported on the kernel backend (no-op).',
);
}

// `runAsync` selects the kernel execution path. NOTE: this is a kernel-
// specific use of the option — the Thrift backend hardcodes `runAsync: true`
// on the wire and never reads `options.runAsync`, so the field is a no-op
Expand All @@ -212,7 +202,9 @@ export default class KernelSessionBackend implements ISessionBackend {
// the backend polls `status()` to terminal in `waitUntilReady()` and
// materialises results via `awaitResult()`. `queryTimeout` is a no-op
// here too.
const runAsync = options.runAsync ?? false;
// Configured staging completes before the facade returns, so retain inline
// results even when the caller requests asynchronous execution.
const runAsync = (options.runAsync ?? false) && options.stagingAllowedLocalPath === undefined;
// `queryTimeout` is a NO-OP on the kernel (kernel) backend. It is the JDBC
// `setQueryTimeout` knob which — per the option's JSDoc — is effective only
// on Compute clusters; SQL Warehouses (what kernel targets) use the
Expand Down
6 changes: 6 additions & 0 deletions native/kernel/index.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions tests/unit/DBSQLSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Status from '../../lib/dto/Status';
import DBSQLOperation from '../../lib/DBSQLOperation';
import { PrimaryKeysRequest } from '../../lib/contracts/IDBSQLSession';
import ISessionBackend from '../../lib/contracts/ISessionBackend';
import IOperationBackend from '../../lib/contracts/IOperationBackend';
import ParameterError from '../../lib/errors/ParameterError';
import { DBSQLParameter } from '../../lib/DBSQLParameter';
import { TSessionHandle, TProtocolVersion, TOperationState, TStatusCode } from '../../thrift/TCLIService_types';
Expand Down Expand Up @@ -883,3 +884,38 @@ describe('DBSQLSession', () => {
});
});
});

describe('DBSQLSession native staging dispatch', () => {
it('closes a failed native operation and preserves its error when close also fails', async () => {
const error = new Error('completion failed');
const close = sinon.stub().rejects(new Error('close failed'));
const op = {
id: 'volume-operation',
completeStagingOperation: sinon.stub().rejects(error),
close,
} as unknown as IOperationBackend;
const session = new DBSQLSession({
backend: { id: 'volume-session', executeStatement: async () => op } as unknown as ISessionBackend,
context: new ClientContextStub(),
});
const actual = await session.executeStatement('GET remote TO local', { stagingAllowedLocalPath: [] }).then(
() => expect.fail('completion should fail'),
(failure) => failure,
);
expect(actual).to.equal(error);
expect(close.callCount).to.equal(1);
});

for (const configured of [false, true]) {
it(`calls native completion only when local paths are ${configured ? 'configured' : 'omitted'}`, async () => {
const completeStagingOperation = sinon.stub().resolves();
const op = { id: 'volume-operation', completeStagingOperation } as unknown as IOperationBackend;
const session = new DBSQLSession({
backend: { id: 'volume-session', executeStatement: async () => op } as unknown as ISessionBackend,
context: new ClientContextStub(),
});
await session.executeStatement('REMOVE remote', configured ? { stagingAllowedLocalPath: [] } : {});
expect(completeStagingOperation.callCount).to.equal(configured ? 1 : 0);
});
}
});
Loading
Loading