Skip to content

Commit e06a5e8

Browse files
committed
sea-operation: cancel/close/finished lifecycle for SEA operations
Wraps napi Statement.cancel/close. finished() is a no-op for M0 (kernel Statement::execute.await blocks until complete; no polling needed). cancel mid-fetch propagates within 200ms via kernel's async cancellation token. Implementation: - lib/sea/SeaOperationLifecycle.ts — standalone helpers (seaCancel, seaClose, seaFinished, failIfNotActive) over a structurally-typed SeaStatementHandle so impl-results can pick them up cleanly. - lib/sea/SeaOperationBackend.ts — IOperationBackend impl that composes the lifecycle helpers; fetch* methods are stubbed and owned by the parallel sea-results branch. Tests: - 27 unit tests (lifecycle helpers + backend integration) - 4 e2e tests against pecotesting — cancel latency 64-80ms, cancel-mid-fetch throws OperationStateError(Canceled), close idempotent, finished() resolves <50ms. Includes a binding-side fix in native/sea/src/{statement,connection}.rs to keep the kernel's parent Statement alive alongside the ExecutedStatement. Without this fix, Statement::Drop invalidates the produced ExecutedStatement via the kernel's ValidityFlag and every cancel/close on the resulting JS Statement throws InvalidStatementHandle. Required because the operation feature's 200ms cancel acceptance is unreachable otherwise.
1 parent 0a5ee91 commit e06a5e8

6 files changed

Lines changed: 1288 additions & 17 deletions

File tree

lib/sea/SeaOperationBackend.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) 2026 Databricks, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/**
16+
* `IOperationBackend` implementation for the SEA path.
17+
*
18+
* Round 1 (impl-operation) lands the **lifecycle** methods:
19+
* - `cancel()` — forwards to napi `Statement.cancel()`,
20+
* - `close()` — forwards to napi `Statement.close()`,
21+
* - `finished({progress, callback})` — M0 no-op (kernel
22+
* `Statement::execute().await` already blocks until the statement
23+
* is in a terminal state, so by the time we have a `Statement`
24+
* handle the operation has already finished).
25+
*
26+
* Fetch / metadata / status methods are intentionally stubbed; they
27+
* are owned by the parallel impl-results feature on
28+
* `~/databricks-sql-nodejs-sea-WT/results` (`sea-results`). At
29+
* integration time impl-results either:
30+
* (a) replaces this file with its own SeaOperationBackend that
31+
* imports `SeaOperationLifecycle` for cancel/close/finished, or
32+
* (b) extends the fetch methods here while keeping the lifecycle
33+
* methods unchanged.
34+
* Both directions land cleanly with this file shape — the lifecycle
35+
* helpers are factored out into `SeaOperationLifecycle.ts` so neither
36+
* branch's diff touches the other's call sites.
37+
*/
38+
39+
import { v4 as uuid } from 'uuid';
40+
import {
41+
TGetOperationStatusResp,
42+
TGetResultSetMetadataResp,
43+
TOperationState,
44+
TStatusCode,
45+
} from '../../thrift/TCLIService_types';
46+
import IOperationBackend from '../contracts/IOperationBackend';
47+
import IClientContext from '../contracts/IClientContext';
48+
import Status from '../dto/Status';
49+
import HiveDriverError from '../errors/HiveDriverError';
50+
import {
51+
SeaStatementHandle,
52+
SeaOperationLifecycleState,
53+
createLifecycleState,
54+
seaCancel,
55+
seaClose,
56+
seaFinished,
57+
failIfNotActive,
58+
} from './SeaOperationLifecycle';
59+
60+
interface SeaOperationBackendOptions {
61+
/**
62+
* The napi `Statement` handle returned by
63+
* `Connection.executeStatement(...)`. Typed structurally so unit
64+
* tests can hand in a mock without loading the native binding.
65+
*/
66+
statement: SeaStatementHandle;
67+
/**
68+
* Driver-level context — used by lifecycle helpers for logging.
69+
*/
70+
context: IClientContext;
71+
/**
72+
* Optional operation id. The kernel doesn't expose a stable
73+
* statement-id string yet (it's an internal `Uuid` on the
74+
* `ExecutedStatementHandle` trait but not surfaced through the
75+
* napi binding's public d.ts). For M0 we synthesize a client-side
76+
* UUID so the public `id` getter on `DBSQLOperation` returns
77+
* something stable; impl-results will swap this out for the
78+
* kernel-provided id once the binding exposes it.
79+
*/
80+
id?: string;
81+
}
82+
83+
const NOT_IMPLEMENTED_FETCH =
84+
'SEA result fetching is owned by the parallel impl-results feature ' +
85+
'(branch sea-results) and not wired in this commit.';
86+
87+
export default class SeaOperationBackend implements IOperationBackend {
88+
private readonly statement: SeaStatementHandle;
89+
90+
private readonly context: IClientContext;
91+
92+
private readonly _id: string;
93+
94+
private readonly lifecycle: SeaOperationLifecycleState = createLifecycleState();
95+
96+
constructor({ statement, context, id }: SeaOperationBackendOptions) {
97+
this.statement = statement;
98+
this.context = context;
99+
this._id = id ?? uuid();
100+
}
101+
102+
public get id(): string {
103+
return this._id;
104+
}
105+
106+
/**
107+
* SEA always returns row results from `executeStatement` (the
108+
* kernel doesn't surface a separate "no result set" path the way
109+
* Thrift does via `TOperationHandle.hasResultSet`). For M0 we
110+
* report `true` so the public-facade `DBSQLOperation.fetchChunk`
111+
* proceeds to call through. DDL statements (which produce no
112+
* rows) will be handled by impl-results returning an empty Arrow
113+
* batch from `fetchNextBatch`.
114+
*/
115+
public get hasResultSet(): boolean {
116+
return true;
117+
}
118+
119+
/**
120+
* Pre-flight gate used by every fetch* method. Exposed as
121+
* protected so impl-results can call it without re-importing
122+
* from `SeaOperationLifecycle`.
123+
*/
124+
protected ensureActive(): void {
125+
failIfNotActive(this.lifecycle);
126+
}
127+
128+
// ------------------------------------------------------------------
129+
// Fetch / metadata / status — stubs owned by impl-results.
130+
// ------------------------------------------------------------------
131+
132+
public async fetchChunk(_options: {
133+
limit: number;
134+
disableBuffering?: boolean;
135+
}): Promise<Array<object>> {
136+
// Active-state gate is still meaningful here so cancel-mid-fetch
137+
// tests can drive against this stub: a fetch issued after cancel
138+
// throws the cancelled error rather than the not-implemented one.
139+
this.ensureActive();
140+
throw new HiveDriverError(NOT_IMPLEMENTED_FETCH);
141+
}
142+
143+
public async hasMore(): Promise<boolean> {
144+
this.ensureActive();
145+
throw new HiveDriverError(NOT_IMPLEMENTED_FETCH);
146+
}
147+
148+
public async status(_progress: boolean): Promise<TGetOperationStatusResp> {
149+
// For M0 the operation is always finished by the time we have a
150+
// Statement handle. Synthesize a FINISHED_STATE response so any
151+
// facade-level callers (`DBSQLOperation.status`, public surface
152+
// via `IOperation.status`) get a sensible answer without
153+
// throwing. Richer status fields (numModifiedRows, displayMessage,
154+
// progressUpdateResponse) defer to M1.
155+
if (this.lifecycle.isCancelled) {
156+
return {
157+
status: { statusCode: TStatusCode.SUCCESS_STATUS },
158+
operationState: TOperationState.CANCELED_STATE,
159+
} as TGetOperationStatusResp;
160+
}
161+
if (this.lifecycle.isClosed) {
162+
return {
163+
status: { statusCode: TStatusCode.SUCCESS_STATUS },
164+
operationState: TOperationState.CLOSED_STATE,
165+
} as TGetOperationStatusResp;
166+
}
167+
return {
168+
status: { statusCode: TStatusCode.SUCCESS_STATUS },
169+
operationState: TOperationState.FINISHED_STATE,
170+
} as TGetOperationStatusResp;
171+
}
172+
173+
public async getResultMetadata(): Promise<TGetResultSetMetadataResp> {
174+
this.ensureActive();
175+
throw new HiveDriverError(NOT_IMPLEMENTED_FETCH);
176+
}
177+
178+
// ------------------------------------------------------------------
179+
// Lifecycle — owned by impl-operation.
180+
// ------------------------------------------------------------------
181+
182+
public async waitUntilReady(options?: {
183+
progress?: boolean;
184+
callback?: (progress: TGetOperationStatusResp) => unknown;
185+
}): Promise<void> {
186+
// `IOperationBackend.waitUntilReady` is the polling-loop entry on
187+
// Thrift (`ThriftOperationBackend.waitUntilReady`); for SEA M0 it
188+
// shares the implementation with `finished()` because both have
189+
// the same semantics here (the operation is already in a terminal
190+
// state by the time we have a Statement handle).
191+
return seaFinished(this.lifecycle, options);
192+
}
193+
194+
public async cancel(): Promise<Status> {
195+
return seaCancel(this.lifecycle, this.statement, this.context, this._id);
196+
}
197+
198+
public async close(): Promise<Status> {
199+
return seaClose(this.lifecycle, this.statement, this.context, this._id);
200+
}
201+
}

0 commit comments

Comments
 (0)