[SEA-NodeJS] Sync execute via directResults (executeStatementDirect): fix CREATE, drop close-drives, keep cancel#426
Conversation
The default sync path (`runAsync: false`) now calls the kernel's additive `Connection.executeStatementDirect` instead of `executeStatementCancellable`. The kernel runs ExecuteStatement with a bounded server inline wait and returns WITHOUT polling past it; the session feature-detects the arm via `awaitResult`: a fast query comes back as a terminal `Statement` (result inline) → wrapped with the operation backend's `statement` arm; a slow one as an `AsyncStatement` → the `asyncStatement` arm. Because the returned handle always corresponds to a server-owned statement: - fire-and-forget CREATE/INSERT commits (server runs it inline in the POST); - `close()` is a clean release, never a drive-to-terminal (no close-drives); - a long query stays cancellable via `op.cancel()` (~150-300ms), Thrift parity; - errors surface at `executeStatement`, matching Thrift / Python use_kernel. Requires the kernel's additive directResults execute (databricks/databricks-sql-kernel#140). `execute()` on the kernel is unchanged, so Python `use_kernel` needs no change. Regenerated napi types add `executeStatementDirect`. Validated e2e (pecotesting): CREATE fire-and-forget commits, 100k read, error at execute, mid-run cancel, close() cheap (~120ms) on an abandoned long query. Unit: SEA suite 260 passing; eslint clean. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
| // `directReturnsRunning` is set — a pending `AsyncStatement` (Running arm), | ||
| // the two arms `SeaSessionBackend.executeStatement` feature-detects via | ||
| // `awaitResult`. | ||
| public directReturnsRunning = false; |
There was a problem hiding this comment.
🔴 F1 — The AsyncStatement (slow-query) arm is completely untested (High confidence — flagged by all 5 reviewers; verified)
This PR adds directReturnsRunning and an executeStatementDirect fake that can return a FakeAsyncStatement — but no test ever sets directReturnsRunning = true, and no new it(...) case was added. The flag is only referenced at its declaration (here), its comment, and the if inside the mock body.
As a result, the branch this entire PR exists to add — SeaSessionBackend.executeStatement detecting awaitResult and constructing new SeaOperationBackend({ asyncStatement }) — has zero coverage. Every existing sync-path test silently flows through the terminal {statement} arm, so the added mock plumbing is currently dead code. A regression that inverted the feature-detect, dropped the awaitResult check, or broke the async handoff would pass CI.
Suggested fix: add ≥1 test that sets connection.directReturnsRunning = true, calls the sync-default executeStatement, and asserts the returned op drives the polling arm (status()/awaitResult()) and that op.cancel() reaches the async statement's cancel() mid-run — the stated purpose of the PR. Pair it with an assertion contrasting the terminal arm.
| @@ -183,24 +183,34 @@ export default class SeaSessionBackend implements ISessionBackend { | |||
| options.queryTimeout !== undefined ? numberToInt64(options.queryTimeout).toNumber() : undefined; | |||
|
|
|||
| if (!runAsync) { | |||
There was a problem hiding this comment.
🟡 F3 — Stale block comment contradicts the new code (Moderate confidence — 2 reviewers; verified)
This new inline comment correctly describes directResults, but the runAsync selector comment block just above (lines ~166–173) still says the DEFAULT path "Route[s] through executeStatementCancellable: the kernel blocks on execute() … The blocking drive runs in the operation backend's result()" — exactly the behavior this PR removes. The two comments in the same method now contradict each other.
Fix: update the 166–173 block to describe directResults. (There's also a trivially stale comment at execution.test.ts:699 still referencing executeStatementCancellable.)
| } | ||
| // Feature-detect the arm: only `AsyncStatement` (the Running arm) exposes | ||
| // `awaitResult`; the terminal `Statement` (Completed arm) does not. | ||
| const asAsync = direct as unknown as SeaNativeAsyncStatement; |
There was a problem hiding this comment.
🟡 F4 — as unknown as double-casts are avoidable; a user-defined type guard is safer (Moderate confidence — language + architecture)
direct is genuinely typed Statement | AsyncStatement (the real union, not any), so direct as unknown as … here and on the statement arm launder away type information the compiler already has. A user-defined type guard narrows both arms cast-free and compiles clean under strict mode:
function isAsyncStatement(x: SeaStatement | SeaNativeAsyncStatement): x is SeaNativeAsyncStatement {
return typeof (x as SeaNativeAsyncStatement).awaitResult === 'function';
}Then: if (isAsyncStatement(direct)) { return new SeaOperationBackend({ asyncStatement: direct, ... }); } with no casts. User-defined guards are already idiomatic in this repo (lib/result/utils.ts isString). Optional but recommended; pair with let direct: SeaStatement | SeaNativeAsyncStatement;.
| // Feature-detect the arm: only `AsyncStatement` (the Running arm) exposes | ||
| // `awaitResult`; the terminal `Statement` (Completed arm) does not. | ||
| const asAsync = direct as unknown as SeaNativeAsyncStatement; | ||
| if (direct !== null && typeof asAsync.awaitResult === 'function') { |
There was a problem hiding this comment.
🟡 F5 — Inconsistent null/undefined defensive handling (Moderate confidence — security, devils-advocate, language; verified)
The kernel contract (Promise<Statement | AsyncStatement>) never returns null, so direct !== null is effectively dead. It's also inconsistent: it guards only the async-arm property read — a null (or undefined) direct falls through to the {statement} arm. Verified: SeaOperationBackend's providedCount counts statement !== undefined, so statement: null is accepted (count 1) and constructs a backend wrapping a dead handle; failure is then deferred to first fetch/close as an opaque TypeError rather than a mapped driver error. (undefined would instead throw at the property read here.) Only triggers on a kernel contract violation, so this is latent/defensive — not a live bug.
Fix (optional): reject a non-object direct up front via logAndMapError, or drop the dead null check; if keeping it, use direct != null to cover both null and undefined.
Review Findings SummaryOut-of-line findings (no specific file:line)🟠 F2 — "drop close-drives" is not delivered: the Verified: This is dead-but-tested code: a reader trusting the PR title will believe close-drives is gone, and the surviving test block misdirects coverage (it looks like sync-path coverage but tests a path production no longer reaches). Fix: either remove the 🔵 F6 — Feature-detection by method-presence is a fragile API boundary (design-debt) (Low confidence — architecture raised; security + language verified it's correct today)
Fix (optional): request a tagged discriminator from the kernel team, or add a defensive contract comment in 🔵 F7 — Loader doesn't validate
Fix (optional): add |
… null + stale comments
- F1: add coverage for the directResults Running (AsyncStatement) arm — the
branch the PR exists to add. Three tests via the fake's `directReturnsRunning`:
(a) a still-running query routes through the AsyncStatement arm and is driven
via `status()`/`waitUntilReady()`; (b) `op.cancel()` reaches the running
statement's `cancel()`; (c) contrast — a fast query routes through the terminal
`Statement` arm and cancel reaches it there. Previously `directReturnsRunning`
was never set, so the async arm had zero coverage.
- F4: replace the `as unknown as` double-casts with a cast-free user-defined type
guard `isSeaAsyncStatement` (the napi `Statement`/`AsyncStatement` are the exact
alias types, so no laundering is needed). Idiomatic, narrows both arms.
- F5: reject a null/undefined `direct` up front via `logAndMapError`
(HiveDriverError) instead of the inconsistent `direct !== null` guard that let a
null fall through to the `{statement}` arm and defer an opaque TypeError.
- F3: update the stale `runAsync` selector comment (still described
`executeStatementCancellable` + blocking `result()`) to directResults; fix the
stale `executeStatementCancellable` comment in the test.
- Document the `queryTimeout`→`wait_timeout=Ns`+CANCEL interaction (a timeout
shorter than the query cancels it rather than returning the Running handle).
SEA unit suite 263 passing (3 new); eslint clean.
Co-authored-by: Isaac
Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
…rect-path docs executeStatementDirect JSDoc now reflects: no wait_timeout field (server default inline wait + auto-close), the awaitResult feature-detect contract, and the queryTimeout->CANCEL interaction. Doc-only; no API surface change. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
|
Addressed all findings in F1 — AsyncStatement (Running) arm untested (FIXED). Added three tests driving the fake's F4 — avoidable F5 — inconsistent null handling (FIXED). Replaced the dead/inconsistent F3 — stale comments (FIXED). Updated the eslint clean; SEA unit suite 263 passing. |
What
The default sync path (
runAsync: false) now calls the kernel's additiveConnection.executeStatementDirect(directResults) instead ofexecuteStatementCancellable. The session feature-detects the arm viaawaitResult:Statement(result inline) → operation backend'sstatementarm;AsyncStatement(poll/cancel handle) →asyncStatementarm.Why Node needs directResults (and Python doesn't)
Mid-run cancellation needs a handle to the running statement during the run. Where that handle can come from depends on the driver's concurrency model:
op.cancel(), andopis the return value ofexecuteStatement. A blockingexecute()that drives the query to completion would only resolveopafter the query finishes — leaving nothing to cancel mid-run. (The event loop isn't frozen —awaityields — butopsimply doesn't exist as a value until the call resolves.) directResults makesexecuteStatementreturn the handle right after the ~10s server inline wait, while the statement is still running, soopexists during the run and a later event-loop turn (a timeout, SIGINT, a UI "stop") can callop.cancel().cursor.cancel()), which exists beforeexecute(), backed by aStatementCancellerregistered up front. Because Python is multi-threaded, that canceller fires from another thread while the blockingexecute()is still running. Nothing needs to be returned early — so Pythonuse_kernelkeeps the plain blockingexecute()with no change.(First-window caveat: the statement id isn't known until the inline-wait POST returns, so the first ~10s is uncancellable on every backend, Thrift included.)
The three fixes (a server-owned handle)
close()is a clean release (~120ms on a forever-query), never a drive-to-terminal.op.cancel()stops a long query (~150–300ms).Slow-DDL behavior — identical to Thrift (verified side-by-side)
For a statement longer than the inline wait,
executeStatementreturns a RUNNING handle (still executing server-side). Outcome, same on both backends:executeStatement(slow CREATE)await op.fetchAll()/op.finished()await op.close()with no prior fetch/finishedclose()cancels the still-running statementSo fire-and-forget commits for fast DDL; slow DDL must be awaited (
fetchAll/finished) or it's cancelled onclose()— exactly as Thrift behaves today.Error timing
Query errors surface eagerly at
executeStatement— matching Python on both backends (Thrift anduse_kernelalso raise at execute; verified 2×2). Node-Thrift is the lone outlier that defers the error to fetch. Eager is the majority, fail-fast behavior — no change needed.Dependency
Requires databricks/databricks-sql-kernel#140 (additive
execute_direct). The kernel'sexecute()is unchanged, so Pythonuse_kernelneeds no change. Supersedes #425.Testing
close()cheap on an abandoned long query.This pull request and its description were written by Isaac.