feat(sql): expose durable job ids on query handles (#4941)

Expose the durable SQL registry id separately from the connection-scoped
query UUID. Rust and Python query handles and descriptions expose
optional `job_id`; result readers preserve it in schema metadata under
`x-lancedb-sql-job-id`, without adding business columns. Clients can
reopen that id with `open_job`.

Poll success and failure metadata update the same handle. A failure
before the first successful poll retains its job id and original
structured error. The cancellation documentation distinguishes the
Flight waiter from `cancel_job(job_id)`.

Validation on the designated EC2: the two focused error interoperability
tests and Ruff passed on `a8798e81`; delivery has the identical tree.
The preceding 51 Rust SQL tests and earlier 31 native Python SQL tests
are reused for unchanged coverage. Matching-runtime independent
acceptance remains pending.

Companion server change: https://github.com/lancedb/sophon/pull/10049.

Refs ENT-3562.
This commit is contained in:
Xuanwo
2026-11-10 19:47:30 +08:00
committed by GitHub
parent 3851938b6e
commit c0bd83ef29
9 changed files with 516 additions and 81 deletions
+4
View File
@@ -501,6 +501,8 @@ class JobDescription:
class SqlQuery:
@property
def id(self) -> UUID: ...
@property
def job_id(self) -> Optional[str]: ...
async def describe(self) -> QueryDescription: ...
async def reader(self) -> RecordBatchStream: ...
async def cancel(self) -> None: ...
@@ -509,6 +511,8 @@ class QueryDescription:
@property
def id(self) -> UUID: ...
@property
def job_id(self) -> Optional[str]: ...
@property
def status(self) -> str: ...
@property
def progress(self) -> Optional[float]: ...
+11 -2
View File
@@ -1515,7 +1515,10 @@ class DBConnection(EnforceOverrides):
This submits through :meth:`execute_query_async` and waits until the
initial result stream is readable. It does not wait for the full query
to finish.
to finish. A statement that ran as a server-side SQL job exposes the
durable Job Registry id in the reader schema metadata under
``x-lancedb-sql-job-id``. Use :meth:`execute_query_async` when the
query handle itself is needed.
"""
return self.execute_query_async(
query,
@@ -1554,6 +1557,9 @@ class DBConnection(EnforceOverrides):
stops a directly streamed query; deferred execution requires explicit
cancellation. Open ``reader()`` directly: a streamed query needs its
reader to make progress and reports ``running`` until the stream ends.
Once a server-side SQL job exists, ``describe().job_id`` exposes its
durable Job Registry id while the query is still running. This is
separate from the connection-scoped query ``id``.
LanceDB Embedded connections do not support SQL.
"""
@@ -4128,7 +4134,10 @@ class AsyncConnection(object):
This submits through :meth:`execute_query_async` and waits until the
initial result stream is readable. It does not wait for the full query
to finish.
to finish. A statement that ran as a server-side SQL job exposes the
durable Job Registry id in the reader schema metadata under
``x-lancedb-sql-job-id``. Use :meth:`execute_query_async` when the
query handle itself is needed.
"""
submitted = await self.execute_query_async(
query,
+30 -2
View File
@@ -104,6 +104,15 @@ class AsyncQuery:
"""The stable identifier scoped to the connection that submitted it."""
return self._inner.id
@property
def job_id(self) -> Optional[str]:
"""The durable Job Registry id once the server reports this query as a job.
A running query reports it after :meth:`describe` observes the latest
server status. This is separate from the connection-scoped ``id``.
"""
return self._inner.job_id
async def describe(self) -> QueryDescription:
"""Get a point-in-time description of the query."""
return await self._inner.describe()
@@ -118,7 +127,12 @@ class AsyncQuery:
return AsyncRecordBatchReader(await self._inner.reader())
async def cancel(self) -> None:
"""Request cancellation of the query."""
"""Request cancellation of this query's Flight execution.
A durable server-side job may reject this operation because its Flight
waiter cannot cancel the registry job. Use ``connection.cancel_job()``
with ``job_id`` to cancel that job.
"""
await self._inner.cancel()
@@ -133,6 +147,15 @@ class Query:
"""The stable identifier scoped to the connection that submitted it."""
return self._inner.id
@property
def job_id(self) -> Optional[str]:
"""The durable Job Registry id once the server reports this query as a job.
A running query reports it after :meth:`describe` observes the latest
server status. This is separate from the connection-scoped ``id``.
"""
return self._inner.job_id
def describe(self) -> QueryDescription:
"""Get a point-in-time description of the query."""
return LOOP.run(self._inner.describe())
@@ -159,7 +182,12 @@ class Query:
return pa.RecordBatchReader.from_batches(reader.schema, batches())
def cancel(self) -> None:
"""Request cancellation of the query."""
"""Request cancellation of this query's Flight execution.
A durable server-side job may reject this operation because its Flight
waiter cannot cancel the registry job. Use ``connection.cancel_job()``
with ``job_id`` to cancel that job.
"""
LOOP.run(self._inner.cancel())
+10
View File
@@ -20,6 +20,7 @@ NIL_QUERY_ID = UUID(int=0)
class FakeNativeQuery:
id = UUID("0198f1b2-c3d4-7e5f-8123-456789abcdef")
job_id = None
async def reader(self):
return pa.table({"value": [1, 2]})
@@ -68,6 +69,15 @@ def test_query_id_is_uuid():
assert Query(query).id == query.id
def test_query_job_id_is_optional_and_separate_from_query_id():
native = FakeNativeQuery()
native.job_id = "j1-durable"
query = AsyncQuery(native)
assert query.id != query.job_id
assert query.job_id == "j1-durable"
assert Query(query).job_id == "j1-durable"
def test_connection_serializes_sql_host_override():
endpoint = "grpc+tls://sql.example.com:10026"
restored = lancedb.deserialize_conn(
+9 -2
View File
@@ -31,6 +31,11 @@ impl Query {
self.inner.id()
}
#[getter]
pub fn job_id(&self) -> Option<String> {
self.inner.job_id()
}
pub fn describe(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner.clone();
future_into_py(self_.py(), async move {
@@ -63,6 +68,7 @@ impl Query {
#[derive(Clone)]
pub struct QueryDescription {
id: Uuid,
job_id: Option<String>,
status: String,
progress: Option<f64>,
expires_at: Option<DateTime<Utc>>,
@@ -72,8 +78,8 @@ pub struct QueryDescription {
impl QueryDescription {
fn __repr__(&self) -> String {
format!(
"QueryDescription(id={:?}, status={:?}, progress={:?}, expires_at={:?})",
self.id, self.status, self.progress, self.expires_at
"QueryDescription(id={:?}, job_id={:?}, status={:?}, progress={:?}, expires_at={:?})",
self.id, self.job_id, self.status, self.progress, self.expires_at
)
}
}
@@ -82,6 +88,7 @@ impl From<lancedb::sql::QueryDescription> for QueryDescription {
fn from(description: lancedb::sql::QueryDescription) -> Self {
Self {
id: description.id,
job_id: description.job_id,
status: description.status.to_string(),
progress: description.progress,
expires_at: description.expires_at,
+205 -61
View File
@@ -19,11 +19,12 @@ use arrow_flight::{
FlightDescriptor, FlightEndpoint, FlightInfo, PollInfo,
};
use arrow_schema::{Schema, SchemaRef};
use futures::TryStreamExt;
use futures::{StreamExt, TryStreamExt};
use http::header::{HeaderMap, HeaderName, HeaderValue};
use prost::Message;
use serde::{Serialize, de::DeserializeOwned};
use tokio::sync::{Mutex, Notify, OnceCell, mpsc};
use tonic::Request;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
use uuid::Uuid;
@@ -87,6 +88,12 @@ struct ResultEndpointStream {
stream: FlightRecordBatchStream,
request_id: String,
read_timeout: Duration,
job_id: Option<String>,
}
struct PollResult {
info: PollInfo,
job_id: Option<String>,
}
struct PreparedSqlResult {
@@ -233,10 +240,19 @@ impl SqlClient {
materialized_view_job_handle,
)
.await?;
let query = match opened.submission {
let stream::OpenedSubmission {
submission,
materialized_view_job_handle_supported,
job_id,
} = opened;
let query = match submission {
Submission::Stream(response) => {
let query =
Arc::new(StreamingQuery::new(query_id, self.inner.clone(), response));
let query = Arc::new(StreamingQuery::new(
query_id,
self.inner.clone(),
response,
job_id.clone(),
));
self.queries
.insert(query_id, RegisteredQuery::Streamed(query.clone()));
Query::new(Arc::new(StreamingQueryHandle::new(query)))
@@ -247,6 +263,7 @@ impl SqlClient {
self.inner.clone(),
request.default_namespace_path,
poll_info,
job_id,
)?);
self.queries
.insert(query_id, RegisteredQuery::Polled(query.clone()));
@@ -255,8 +272,7 @@ impl SqlClient {
};
Ok(SubmittedQuery {
query,
materialized_view_job_handle_supported: opened
.materialized_view_job_handle_supported,
materialized_view_job_handle_supported,
})
})
.await
@@ -283,6 +299,7 @@ impl SqlClient {
self.inner.clone(),
default_namespace_path.to_vec(),
poll_info,
None,
)?);
self.queries
.insert(query_id, RegisteredQuery::Polled(query.clone()));
@@ -383,16 +400,29 @@ impl SqlClientInner {
&self,
descriptor: FlightDescriptor,
default_namespace_path: &[String],
) -> Result<Option<PollInfo>> {
) -> Result<Option<PollResult>> {
let request_id = uuid::Uuid::new_v4().to_string();
let mut client = self
.client_with_headers(default_namespace_path, &request_id)
.await
.map_err(|err| sql_error(&request_id, err))?;
match tokio::time::timeout(STATUS_POLL_TIMEOUT, client.poll_flight_info(descriptor)).await {
Ok(result) => result
.map(Some)
.map_err(|err| sql_flight_error(&request_id, &err)),
let connection = self.connection(&request_id).await?;
let headers = self.headers(default_namespace_path, &request_id).await?;
let metadata = client_with_headers(connection.client.clone(), &headers)?
.metadata()
.clone();
let mut request = Request::new(descriptor);
*request.metadata_mut() = metadata;
match tokio::time::timeout(
STATUS_POLL_TIMEOUT,
connection.client.clone().poll_flight_info(request),
)
.await
{
Ok(Ok(response)) => {
let job_id = sql_job_id_from_metadata(response.metadata());
Ok(Some(PollResult {
info: response.into_inner(),
job_id,
}))
}
Ok(Err(status)) => Err(sql_status_error(&request_id, &status)),
Err(_) => Ok(None),
}
}
@@ -401,7 +431,7 @@ impl SqlClientInner {
&self,
descriptor: FlightDescriptor,
default_namespace_path: &[String],
) -> Result<PollInfo> {
) -> Result<PollResult> {
let read_timeout = resolve_timeout(
self.client_config.timeout_config.read_timeout,
"LANCE_CLIENT_READ_TIMEOUT",
@@ -413,12 +443,18 @@ impl SqlClientInner {
loop {
let started = Instant::now();
let request_id = uuid::Uuid::new_v4().to_string();
let mut client = self
.client_with_headers(default_namespace_path, &request_id)
.await?;
let result =
tokio::time::timeout(read_timeout, client.poll_flight_info(descriptor.clone()))
.await;
let connection = self.connection(&request_id).await?;
let headers = self.headers(default_namespace_path, &request_id).await?;
let metadata = client_with_headers(connection.client.clone(), &headers)?
.metadata()
.clone();
let mut request = Request::new(descriptor.clone());
*request.metadata_mut() = metadata;
let result = tokio::time::timeout(
read_timeout,
connection.client.clone().poll_flight_info(request),
)
.await;
let poll_info = match result {
Err(_) if retry_count < retry_config.read_retries => {
retry_count += 1;
@@ -426,7 +462,7 @@ impl SqlClientInner {
continue;
}
Err(_) => return Err(sql_error(&request_id, "SQL query poll timed out")),
Ok(Err(FlightError::Tonic(status)))
Ok(Err(status))
if matches!(
status.code(),
tonic::Code::DeadlineExceeded | tonic::Code::Unavailable
@@ -436,8 +472,14 @@ impl SqlClientInner {
tokio::time::sleep(poll_retry_delay(&retry_config, retry_count)).await;
continue;
}
Ok(Err(error)) => return Err(sql_flight_error(&request_id, &error)),
Ok(Ok(poll_info)) => poll_info,
Ok(Err(status)) => return Err(sql_status_error(&request_id, &status)),
Ok(Ok(response)) => {
let job_id = sql_job_id_from_metadata(response.metadata());
PollResult {
info: response.into_inner(),
job_id,
}
}
};
if let Some(delay) = MIN_POLL_INTERVAL.checked_sub(started.elapsed()) {
tokio::time::sleep(delay).await;
@@ -461,17 +503,27 @@ impl SqlClientInner {
let ticket = endpoint.ticket.ok_or_else(|| {
sql_error(&request_id, "SQL result endpoint did not include a ticket")
})?;
let mut endpoint_client = self
.client_with_headers(default_namespace_path, &request_id)
.await?;
let stream = tokio::time::timeout(read_timeout, endpoint_client.do_get(ticket))
.await
.map_err(|_| sql_error(&request_id, "SQL result fetch timed out"))?
.map_err(|err| sql_flight_error(&request_id, &err))?;
let connection = self.connection(&request_id).await?;
let headers = self.headers(default_namespace_path, &request_id).await?;
let metadata = client_with_headers(connection.client.clone(), &headers)?
.metadata()
.clone();
let mut request = Request::new(ticket);
*request.metadata_mut() = metadata;
let response =
tokio::time::timeout(read_timeout, connection.client.clone().do_get(request))
.await
.map_err(|_| sql_error(&request_id, "SQL result fetch timed out"))?
.map_err(|status| sql_status_error(&request_id, &status))?;
let job_id = sql_job_id_from_metadata(response.metadata());
let stream = response
.into_inner()
.map_err(|status| FlightError::Tonic(Box::new(status)));
Ok(ResultEndpointStream {
stream,
stream: FlightRecordBatchStream::new_from_flight_data(stream),
request_id,
read_timeout,
job_id,
})
}
@@ -547,6 +599,7 @@ impl SqlClientInner {
Ok(CancelOutcome::Status(status))
}
#[cfg(test)]
async fn client_with_headers(
&self,
default_namespace_path: &[String],
@@ -684,6 +737,7 @@ impl QueryRegistry {
struct RemoteQuery {
id: Uuid,
job_id: StdMutex<Option<String>>,
client: Arc<SqlClientInner>,
default_namespace_path: Vec<String>,
state: Mutex<PollInfo>,
@@ -713,6 +767,7 @@ impl RemoteQuery {
client: Arc<SqlClientInner>,
default_namespace_path: Vec<String>,
poll_info: PollInfo,
job_id: Option<String>,
) -> Result<Self> {
let expires_at = query_expiration(&poll_info)?;
let terminal_at = OnceLock::new();
@@ -724,6 +779,7 @@ impl RemoteQuery {
};
Ok(Self {
id,
job_id: StdMutex::new(job_id.filter(|job_id| !job_id.is_empty())),
client,
default_namespace_path,
state: Mutex::new(poll_info),
@@ -850,6 +906,44 @@ impl RemoteQuery {
*self.last_accessed.lock().unwrap() = Instant::now();
}
fn job_id(&self) -> Option<String> {
self.job_id.lock().unwrap().clone()
}
fn observe_job_id(&self, job_id: Option<&str>) -> Result<()> {
let Some(job_id) = job_id.filter(|job_id| !job_id.is_empty()) else {
return Ok(());
};
let mut current = self.job_id.lock().unwrap();
match current.as_deref() {
None => *current = Some(job_id.to_string()),
Some(existing) if existing == job_id => {}
Some(existing) => {
return Err(Error::Runtime {
message: format!(
"SQL query changed durable job identity from {existing} to {job_id}"
),
});
}
}
Ok(())
}
fn observe_error_job_id(&self, error: &Error) -> Result<()> {
self.observe_job_id(
error
.remote_error_metadata()
.and_then(|metadata| metadata.job_id.as_deref()),
)
}
fn record_error_job_id<T>(&self, result: Result<T>) -> Result<T> {
if let Err(error) = &result {
self.observe_error_job_id(error)?;
}
result
}
async fn poll_next_state(&self, descriptor: FlightDescriptor) -> Result<PollInfo> {
self.touch();
if self.is_cancellation_requested() {
@@ -874,10 +968,11 @@ impl RemoteQuery {
Err(_) if self.is_cancellation_requested() => {
return Err(self.cancelled_error());
}
result => result?,
result => self.record_error_job_id(result)?,
},
};
self.update_state(&descriptor, updated).await
self.observe_job_id(updated.job_id.as_deref())?;
self.update_state(&descriptor, updated.info).await
}
async fn prepare_result(self: &Arc<Self>) -> Result<PreparedSqlResult> {
@@ -894,8 +989,9 @@ impl RemoteQuery {
result = self.client.open_result_endpoint(
endpoint,
&self.default_namespace_path,
) => result?,
) => self.record_error_job_id(result)?,
};
self.observe_job_id(endpoint_stream.job_id.as_deref())?;
let buffered_batch = tokio::select! {
biased;
_ = self.wait_for_cancellation() => return Err(self.cancelled_error()),
@@ -908,6 +1004,7 @@ impl RemoteQuery {
.ok_or_else(|| Error::Runtime {
message: "SQL result endpoint did not include a schema".to_string(),
})?;
self.observe_job_id(sql_job_id_from_schema(&schema).as_deref())?;
return Ok(PreparedSqlResult {
schema,
next_endpoint: 1,
@@ -925,6 +1022,7 @@ impl RemoteQuery {
.map_err(|err| sql_error(&request_id, err))?,
)
};
self.observe_job_id(sql_job_id_from_schema(&schema).as_deref())?;
return Ok(PreparedSqlResult {
schema,
next_endpoint: 0,
@@ -1001,7 +1099,7 @@ impl RemoteQuery {
result = self.client.open_result_endpoint(
endpoint,
&self.default_namespace_path,
) => result?,
) => self.record_error_job_id(result)?,
});
continue;
}
@@ -1067,24 +1165,22 @@ impl RemoteQuery {
self.touch();
if self.is_cancellation_requested() {
let state = self.state.lock().await.clone();
return query_description(self.id, &state, self.lifecycle());
return query_description(self.id, self.job_id(), &state, self.lifecycle());
}
let state = self.state.lock().await.clone();
let state = if let Some(descriptor) = state.flight_descriptor.clone() {
let poll_guard = tokio::select! {
biased;
_ = self.wait_for_cancellation() => return query_description(
self.id,
&state,
self.lifecycle(),
),
_ = self.wait_for_cancellation() => {
return query_description(self.id, self.job_id(), &state, self.lifecycle())
}
poll_guard = tokio::time::timeout(
STATUS_POLL_TIMEOUT,
self.poll_gate.lock(),
) => poll_guard,
};
let Ok(_poll_guard) = poll_guard else {
return query_description(self.id, &state, self.lifecycle());
return query_description(self.id, self.job_id(), &state, self.lifecycle());
};
let latest = self.state.lock().await.clone();
if latest.flight_descriptor.as_ref() != Some(&descriptor) {
@@ -1092,25 +1188,28 @@ impl RemoteQuery {
} else {
let updated = tokio::select! {
biased;
_ = self.wait_for_cancellation() => return query_description(
self.id,
&latest,
self.lifecycle(),
),
_ = self.wait_for_cancellation() => {
return query_description(self.id, self.job_id(), &latest, self.lifecycle())
}
result = self.client.poll_status(
descriptor.clone(),
&self.default_namespace_path,
) => match result {
Err(_) if self.is_cancellation_requested() => return query_description(
self.id,
&latest,
self.lifecycle(),
),
result => result?,
Err(error) if self.is_cancellation_requested() => {
self.observe_error_job_id(&error)?;
return query_description(
self.id,
self.job_id(),
&latest,
self.lifecycle(),
);
}
result => self.record_error_job_id(result)?,
},
};
if let Some(updated) = updated {
self.update_state(&descriptor, updated).await?
self.observe_job_id(updated.job_id.as_deref())?;
self.update_state(&descriptor, updated.info).await?
} else {
latest
}
@@ -1118,7 +1217,7 @@ impl RemoteQuery {
} else {
state
};
query_description(self.id, &state, self.lifecycle())
query_description(self.id, self.job_id(), &state, self.lifecycle())
}
async fn cancel(&self) -> Result<()> {
@@ -1214,11 +1313,15 @@ impl RemoteQuery {
if self.state.lock().await.flight_descriptor.as_ref() != Some(&descriptor) {
continue;
}
let updated = self.client.poll_continuation(
let updated = self.record_error_job_id(
self.client.poll_continuation(
descriptor.clone(),
&self.default_namespace_path,
).await?;
self.update_state(&descriptor, updated).await?;
)
.await,
)?;
self.observe_job_id(updated.job_id.as_deref())?;
self.update_state(&descriptor, updated.info).await?;
}
_ = notified => {}
}
@@ -1247,6 +1350,10 @@ impl QueryHandle for RemoteQueryHandle {
self.query.id
}
fn job_id(&self) -> Option<String> {
self.query.job_id()
}
async fn describe(&self) -> Result<QueryDescription> {
self.query.describe().await
}
@@ -1286,7 +1393,12 @@ impl QueryHandle for RemoteQueryHandle {
receiver.recv().await.map(|item| (item, receiver))
});
result_start.commit();
Ok(Box::pin(SimpleRecordBatchStream::new(stream, schema)))
let stream: SendableRecordBatchStream =
Box::pin(SimpleRecordBatchStream::new(stream, schema));
Ok(match self.job_id() {
Some(job_id) => attach_sql_job_id(stream, &job_id),
None => stream,
})
}
async fn cancel(&self) -> Result<()> {
@@ -1296,12 +1408,14 @@ impl QueryHandle for RemoteQueryHandle {
fn query_description(
id: Uuid,
job_id: Option<String>,
poll_info: &PollInfo,
lifecycle: QueryLifecycle,
) -> Result<QueryDescription> {
let expires_at = query_expiration(poll_info)?;
Ok(QueryDescription {
id,
job_id,
status: match lifecycle {
QueryLifecycle::Cancelling => QueryStatus::Cancelling,
QueryLifecycle::Cancelled => QueryStatus::Cancelled,
@@ -1317,6 +1431,36 @@ fn query_description(
})
}
fn sql_job_id_from_metadata(metadata: &tonic::metadata::MetadataMap) -> Option<String> {
metadata
.get(SQL_JOB_ID_METADATA_KEY)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
fn sql_job_id_from_schema(schema: &Schema) -> Option<String> {
schema
.metadata()
.get(SQL_JOB_ID_METADATA_KEY)
.filter(|value| !value.is_empty())
.cloned()
}
pub(super) fn attach_sql_job_id(
stream: SendableRecordBatchStream,
job_id: &str,
) -> SendableRecordBatchStream {
let mut metadata = stream.schema().metadata().clone();
metadata.insert(SQL_JOB_ID_METADATA_KEY.to_string(), job_id.to_string());
let schema = Arc::new(stream.schema().as_ref().clone().with_metadata(metadata));
let batch_schema = schema.clone();
let stream = stream.map(move |batch| {
batch.and_then(|batch| batch.with_schema(batch_schema.clone()).map_err(Error::from))
});
Box::pin(SimpleRecordBatchStream::new(stream, schema))
}
fn query_expiration(poll_info: &PollInfo) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
poll_info
.expiration_time
+43 -1
View File
@@ -55,6 +55,7 @@ pub(super) struct StreamingQuery {
cancelled: Notify,
terminal_at: OnceLock<Instant>,
last_accessed: StdMutex<Instant>,
job_id: StdMutex<Option<String>>,
}
struct PreparedStream {
@@ -125,6 +126,7 @@ pub(super) enum Submission {
pub(super) struct OpenedSubmission {
pub(super) submission: Submission,
pub(super) materialized_view_job_handle_supported: bool,
pub(super) job_id: Option<String>,
}
impl StreamingQuery {
@@ -190,6 +192,7 @@ impl StreamingQuery {
}
.map_err(|_| sql_error(&request_id, "SQL query submission timed out"))?
.map_err(|error| sql_status_error(&request_id, &error))?;
let response_job_id = super::sql_job_id_from_metadata(response.metadata());
let negotiated = response
.metadata()
.get(PROTOCOL_HEADER)
@@ -233,6 +236,7 @@ impl StreamingQuery {
return Ok(OpenedSubmission {
submission: Submission::Deferred(info),
materialized_view_job_handle_supported,
job_id: response_job_id,
});
}
}
@@ -245,8 +249,10 @@ impl StreamingQuery {
stream,
request_id,
read_timeout,
job_id: None,
}),
materialized_view_job_handle_supported: false,
job_id: response_job_id,
})
}
@@ -254,6 +260,7 @@ impl StreamingQuery {
id: Uuid,
client: Arc<SqlClientInner>,
response: ResultEndpointStream,
job_id: Option<String>,
) -> Self {
Self {
id,
@@ -263,6 +270,7 @@ impl StreamingQuery {
cancelled: Notify::new(),
terminal_at: OnceLock::new(),
last_accessed: StdMutex::new(Instant::now()),
job_id: StdMutex::new(job_id.filter(|job_id| !job_id.is_empty())),
}
}
@@ -291,6 +299,7 @@ impl StreamingQuery {
};
Ok(QueryDescription {
id: self.id,
job_id: self.job_id(),
status,
progress: (status == QueryStatus::Finished).then_some(1.0),
expires_at: None,
@@ -326,6 +335,29 @@ impl StreamingQuery {
*self.lifecycle.lock().unwrap() == Lifecycle::Cancelled
}
fn job_id(&self) -> Option<String> {
self.job_id.lock().unwrap().clone()
}
fn observe_job_id(&self, job_id: Option<&str>) -> Result<()> {
let Some(job_id) = job_id.filter(|job_id| !job_id.is_empty()) else {
return Ok(());
};
let mut current = self.job_id.lock().unwrap();
match current.as_deref() {
None => *current = Some(job_id.to_string()),
Some(existing) if existing == job_id => {}
Some(existing) => {
return Err(Error::Runtime {
message: format!(
"SQL query changed durable job identity from {existing} to {job_id}"
),
});
}
}
Ok(())
}
fn cancelled_error(&self) -> Error {
Error::JobCancelled {
job_id: Some(self.id.to_string()),
@@ -373,6 +405,7 @@ impl StreamingQuery {
.ok_or_else(|| Error::Runtime {
message: "SQL result stream did not include a schema".to_string(),
})?;
self.observe_job_id(super::sql_job_id_from_schema(&schema).as_deref())?;
if first.is_none() {
self.settle(Lifecycle::Finished);
}
@@ -476,6 +509,10 @@ impl QueryHandle for StreamingQueryHandle {
self.query.id
}
fn job_id(&self) -> Option<String> {
self.query.job_id()
}
async fn describe(&self) -> Result<QueryDescription> {
self.query.describe()
}
@@ -526,7 +563,12 @@ impl QueryHandle for StreamingQueryHandle {
receiver.recv().await.map(|item| (item, receiver))
});
result_start.commit();
Ok(Box::pin(SimpleRecordBatchStream::new(stream, schema)))
let stream: SendableRecordBatchStream =
Box::pin(SimpleRecordBatchStream::new(stream, schema));
Ok(match self.job_id() {
Some(job_id) => super::attach_sql_job_id(stream, &job_id),
None => stream,
})
}
async fn cancel(&self) -> Result<()> {
+177 -10
View File
@@ -160,8 +160,17 @@ impl TestSqlService {
return Response::new(rows);
}
let poll_info = self.defer.then(|| {
let mut info = FlightInfo::new()
.with_endpoint(FlightEndpoint::new().with_ticket(Ticket::new(query.to_string())));
let mut info = FlightInfo::new();
if !query.starts_with("SELECT durable job") {
info = info.with_endpoint(
FlightEndpoint::new().with_ticket(Ticket::new(query.to_string())),
);
}
if query.starts_with("SELECT durable job") {
info = info
.try_with_schema(&self.result_for_query(query).schema())
.unwrap();
}
if self.materialized_view_job_handle_supported {
info.app_metadata = b"early-v1".to_vec().into();
}
@@ -198,6 +207,13 @@ impl TestSqlService {
response
.metadata_mut()
.insert(PROTOCOL_HEADER, PROTOCOL_VERSION.parse().unwrap());
if query.starts_with("SELECT direct durable job") {
let job_id = query.rsplit(' ').next().unwrap();
response.metadata_mut().insert(
SQL_JOB_ID_METADATA_KEY,
format!("job-{job_id}").parse().unwrap(),
);
}
response
}
}
@@ -351,7 +367,14 @@ impl FlightService for TestSqlService {
.insert("x-lancedb-sql-job-id", "job-1".parse().unwrap());
return Err(status);
}
let complete = if query == "SELECT no info" {
if query == "SELECT durable job failure" && stage == 1 {
let mut status = Status::internal("durable SQL job failed");
status
.metadata_mut()
.insert("x-lancedb-sql-job-id", "job-1".parse().unwrap());
return Err(status);
}
let complete = if query == "SELECT no info" || query.starts_with("SELECT durable job") {
stage >= 2
} else {
stage >= 1
@@ -362,11 +385,14 @@ impl FlightService for TestSqlService {
} else {
query.clone()
};
let mut info = FlightInfo::new().with_endpoint(
FlightEndpoint::new()
.with_ticket(Ticket::new(first_ticket))
.with_location("grpc://127.0.0.1:1"),
);
let mut info = FlightInfo::new();
if !(query.starts_with("SELECT durable job") && !complete) {
info = info.with_endpoint(
FlightEndpoint::new()
.with_ticket(Ticket::new(first_ticket))
.with_location("grpc://127.0.0.1:1"),
);
}
if query == "SELECT incremental" && stage > 0 {
info = info.with_endpoint(
FlightEndpoint::new()
@@ -378,13 +404,21 @@ impl FlightService for TestSqlService {
let schema = self.result_for_query(&query).schema();
info = info.try_with_schema(&schema).unwrap();
}
Ok(Response::new(PollInfo {
let mut response = Response::new(PollInfo {
info: (query != "SELECT no info" || stage > 0).then_some(info),
flight_descriptor: (!complete)
.then(|| FlightDescriptor::new_cmd(format!("poll:{}:{query}", stage + 1))),
progress: Some(if complete { 1.0 } else { 0.25 }),
expiration_time: None,
}))
});
if query.starts_with("SELECT durable job") && stage > 0 {
let job_id = query.rsplit(' ').next().unwrap();
response.metadata_mut().insert(
SQL_JOB_ID_METADATA_KEY,
format!("job-{job_id}").parse().unwrap(),
);
}
Ok(response)
}
async fn get_schema(
@@ -730,6 +764,106 @@ async fn parameters_travel_on_one_exchange() {
assert_eq!(headers[0].api_key, "test-key");
}
#[tokio::test]
async fn running_job_backed_queries_expose_their_own_durable_job_id() {
let service = TestSqlService {
adaptive: true,
defer: true,
..Default::default()
};
let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await;
let first = client
.execute(ExecuteQueryRequest::new("SELECT durable job first"))
.await
.unwrap();
let second = client
.execute(ExecuteQueryRequest::new("SELECT durable job second"))
.await
.unwrap();
assert_eq!(first.job_id(), None);
assert_eq!(second.job_id(), None);
let first_description = first.describe().await.unwrap();
let second_description = second.describe().await.unwrap();
assert_eq!(first_description.status, QueryStatus::Running);
assert_eq!(first_description.job_id.as_deref(), Some("job-first"));
assert_eq!(second_description.status, QueryStatus::Running);
assert_eq!(second_description.job_id.as_deref(), Some("job-second"));
assert_ne!(first.id(), second.id());
assert_ne!(first_description.job_id, second_description.job_id);
let reader = first.reader().await.unwrap();
assert_eq!(
reader
.schema()
.metadata()
.get(SQL_JOB_ID_METADATA_KEY)
.map(String::as_str),
Some("job-first")
);
assert_eq!(first.job_id().as_deref(), Some("job-first"));
assert_eq!(second.job_id().as_deref(), Some("job-second"));
let reader_only = client
.execute(ExecuteQueryRequest::new("SELECT durable job reader-only"))
.await
.unwrap();
let reader_only_result = reader_only.reader().await.unwrap();
assert_eq!(
reader_only_result
.schema()
.metadata()
.get(SQL_JOB_ID_METADATA_KEY)
.map(String::as_str),
Some("job-reader-only")
);
let ordinary = client
.execute(ExecuteQueryRequest::new("SELECT ordinary"))
.await
.unwrap();
assert_eq!(ordinary.describe().await.unwrap().job_id, None);
}
#[tokio::test]
async fn direct_job_backed_queries_expose_the_initial_response_job_id() {
let service = TestSqlService {
adaptive: true,
..Default::default()
};
let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await;
let query = client
.execute(ExecuteQueryRequest::new("SELECT direct durable job direct"))
.await
.unwrap();
assert_eq!(query.job_id().as_deref(), Some("job-direct"));
assert_eq!(
query.describe().await.unwrap().job_id.as_deref(),
Some("job-direct")
);
let reader = query.reader().await.unwrap();
assert_eq!(
reader
.schema()
.metadata()
.get(SQL_JOB_ID_METADATA_KEY)
.map(String::as_str),
Some("job-direct")
);
let batches = reader.try_collect::<Vec<_>>().await.unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(
batches[0]
.schema()
.metadata()
.get(SQL_JOB_ID_METADATA_KEY)
.map(String::as_str),
Some("job-direct")
);
}
/// The server plans before it answers, so a refusal is the submission's.
#[tokio::test]
async fn a_refused_statement_fails_its_submission() {
@@ -1213,6 +1347,35 @@ async fn function_failure_preserves_the_isolated_sql_job_id() {
shutdown.send(()).unwrap();
}
#[tokio::test]
async fn fast_durable_job_failure_exposes_the_same_job_id_on_the_query_handle() {
let (client, shutdown) = start_sql_service(
TestSqlService {
adaptive: true,
defer: true,
..Default::default()
},
ClientConfig::default(),
)
.await;
let query = client
.execute(ExecuteQueryRequest {
query: "SELECT durable job failure".into(),
default_namespace_path: vec![],
parameters: None,
})
.await
.unwrap();
assert_eq!(query.job_id(), None);
let Err(Error::JobFailed { job_id, .. }) = collect_result(&query).await else {
panic!("a failed durable query must remain a structured JobFailed error");
};
assert_eq!(job_id.as_deref(), Some("job-1"));
assert_eq!(query.job_id().as_deref(), Some("job-1"));
shutdown.send(()).unwrap();
}
#[tokio::test]
async fn rejected_job_cancellation_does_not_abort_sql_completion() {
let service = TestSqlService {
@@ -1843,6 +2006,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
flight_descriptor: Some(FlightDescriptor::new_cmd("restored")),
..Default::default()
},
None,
)
.unwrap(),
);
@@ -1975,6 +2139,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
flight_descriptor: Some(FlightDescriptor::new_cmd(descriptor)),
..Default::default()
},
None,
)
.unwrap(),
);
@@ -1996,6 +2161,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
expiration_time: Some(Default::default()),
..Default::default()
},
None,
)
.unwrap(),
);
@@ -2012,6 +2178,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
flight_descriptor: Some(FlightDescriptor::new_cmd("stale")),
..Default::default()
},
None,
)
.unwrap(),
);
+27 -3
View File
@@ -44,6 +44,10 @@ impl fmt::Display for QueryStatus {
pub struct QueryDescription {
/// The stable, connection-scoped identifier assigned when the query was submitted.
pub id: Uuid,
/// The durable Job Registry identifier for a server-side SQL job, when the
/// submitted statement ran as one. This identifier can be passed to
/// `open_job`; `id` remains connection-scoped.
pub job_id: Option<String>,
/// The server-visible lifecycle state.
pub status: QueryStatus,
/// Server-reported completion progress, when known. Values are in `[0.0, 1.0]`,
@@ -56,6 +60,9 @@ pub struct QueryDescription {
#[async_trait]
pub(crate) trait QueryHandle: Send + Sync {
fn id(&self) -> Uuid;
fn job_id(&self) -> Option<String> {
None
}
async fn describe(&self) -> Result<QueryDescription>;
async fn reader(&self) -> Result<SendableRecordBatchStream>;
async fn cancel(&self) -> Result<()>;
@@ -68,8 +75,9 @@ pub(crate) trait QueryHandle: Send + Sync {
/// return result tickets. Dropping an unread handle or its reader stops a
/// directly streamed query; deferred execution continues until explicitly
/// cancelled. This applies equally with and without parameters.
/// Identifier lookup is scoped to the connection that submitted the query and
/// is not a durable resume mechanism.
/// [`Self::id`] is scoped to the connection that submitted the query. When the
/// server runs the statement as a SQL job, [`Self::job_id`] exposes the durable
/// Job Registry identifier accepted by `open_job`.
pub struct Query {
handle: Arc<dyn QueryHandle>,
}
@@ -79,6 +87,7 @@ impl std::fmt::Debug for Query {
formatter
.debug_struct("Query")
.field("id", &self.id())
.field("job_id", &self.job_id())
.finish()
}
}
@@ -94,6 +103,16 @@ impl Query {
self.handle.id()
}
/// Return the durable Job Registry identifier when the server has reported
/// one for this query.
///
/// A running query may report it after [`Self::describe`] observes the
/// server's latest status. The connection-scoped [`Self::id`] remains a
/// separate identifier.
pub fn job_id(&self) -> Option<String> {
self.handle.job_id()
}
/// Get a point-in-time description of the query.
///
/// Directly streamed queries stay [`QueryStatus::Running`] until the result
@@ -115,7 +134,12 @@ impl Query {
self.handle.reader().await
}
/// Request cancellation of the query.
/// Request cancellation of this query's Flight execution.
///
/// For a durable server-side SQL job, the operation may return
/// [`Error::NotSupported`] because the Flight waiter cannot cancel the
/// registry job. Use [`Connection::cancel_job`](crate::Connection::cancel_job)
/// with [`Self::job_id`] to cancel that job.
pub async fn cancel(&self) -> Result<()> {
self.handle.cancel().await
}