feat(sql): bind query parameters over Flight DoExchange (#4345)

`execute_query` / `execute_query_async` can now bind values to `$1` /
`$name` placeholders instead of splicing them into the SQL text. The
values travel as Arrow, so a float32 keeps its exact bits and type and a
vector stays a compact `FixedSizeList` (3 KB for 768 dims rather than ~9
KB of decimal literals parsed back as Float64).

```rust
db.execute_query_async("SELECT id FROM docs ORDER BY distance(vector, $vector) LIMIT $k")
    .parameters(batch) // one-row RecordBatch
    .execute()
    .await?;
```

```python
db.execute_query(sql, parameters={"vector": np.float32_vector, "k": 10})
```

- A statement with parameters is sent on one `DoExchange`: the first
message carries the `CommandStatementQuery` descriptor and the parameter
schema, the second the one-row batch, and the result rows stream back on
the same call. Statements without parameters keep `PollFlightInfo` +
`DoGet` unchanged.
- Column `i` binds `$<i+1>`; a column whose name is not a number also
binds `$<name>`. Python accepts a list (positional), a dict (named), or
a one-row `RecordBatch` / `Table`; 1-D numpy / pyarrow arrays become a
`FixedSizeList` of their element type.
- A parameterized query runs on its call: `cancel()`, dropping the
handle before reading, or dropping the reader ends the call and stops
the server-side statement. It is still registered for `describe_query`.
- `Database::execute_query_async` now takes an `ExecuteQueryRequest`
options struct.

Server side: lancedb/sophon#8256.
This commit is contained in:
Jack Ye
2026-10-08 18:23:46 -07:00
committed by GitHub
parent ae50d84e34
commit 4487b1108c
15 changed files with 1267 additions and 67 deletions
+11
View File
@@ -86,6 +86,15 @@ print(query.describe().status)
for batch in query.reader():
print(batch.num_rows)
# Values bind to $1 / $name placeholders and travel as Arrow, so a float32
# stays a float32 and a vector stays a compact fixed-size list:
import numpy as np
reader = db.execute_query(
"SELECT id FROM docs ORDER BY distance(vector, $vector) LIMIT $k",
parameters={"vector": np.random.rand(768).astype(np.float32), "k": 10},
)
# The async connection exposes the same lifecycle without blocking:
# async_db = await lancedb.connect_async(
# "db://analytics",
@@ -201,6 +210,8 @@ listing a storage directory.
::: lancedb.sql.QueryDescription
::: lancedb.sql.QueryParameters
## Materialized Views (Synchronous)
::: lancedb.materialized_view.MaterializedView
+1
View File
@@ -217,6 +217,7 @@ class Connection(object):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[pa.RecordBatch] = None,
) -> SqlQuery: ...
async def describe_query(self, query_id: UUID) -> QueryDescription: ...
async def create_table(
+34 -3
View File
@@ -53,7 +53,7 @@ from .functions import FunctionVersion, UdfDefinition
from .job import AsyncJob, Job, _typed_job
from .sql import AsyncQuery as AsyncSqlQuery
from .sql import Query as SqlQuery
from .sql import QueryDescription
from .sql import QueryDescription, QueryParameters, to_parameter_batch
from .materialized_view import (
AsyncMaterializedView,
MaterializedView,
@@ -1178,6 +1178,7 @@ class DBConnection(EnforceOverrides):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> pa.RecordBatchReader:
"""Execute SQL and return a blocking Arrow reader.
@@ -1188,6 +1189,7 @@ class DBConnection(EnforceOverrides):
return self.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
).reader()
def execute_query_async(
@@ -1195,9 +1197,32 @@ class DBConnection(EnforceOverrides):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> SqlQuery:
"""Start executing SQL and return its query handle.
``parameters`` binds values to the statement's ``$1`` / ``$name``
placeholders. They travel as Arrow rather than as SQL text, so a
float keeps its exact value and type and a vector stays a compact
fixed-size list. A list binds by position, a dict binds by name, and
a one-row ``pyarrow.RecordBatch`` or ``pyarrow.Table`` binds both
ways. A 1-D numpy or pyarrow array becomes a fixed-size list of its
element type, the shape of a vector column:
.. code-block:: python
db.execute_query(
"SELECT id FROM docs ORDER BY distance(vector, $1) LIMIT $2",
parameters=[np.array([0.1, 0.2, 0.3], dtype=np.float32), 10],
)
Parameters are supported in queries, not in DDL or DML. A
parameterized query runs on the call that returns its rows:
cancelling it, or dropping its handle before reading, stops it on the
server. Its status follows that stream as well: ``describe()``
reports ``running`` until the reader has received every row, so open
``reader()`` directly rather than waiting for ``finished``.
Local connections do not support SQL.
"""
raise NotImplementedError("SQL is not supported for this connection type")
@@ -3267,6 +3292,7 @@ class AsyncConnection(object):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> AsyncRecordBatchReader:
"""Execute SQL and return an asynchronous Arrow reader.
@@ -3277,6 +3303,7 @@ class AsyncConnection(object):
submitted = await self.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
)
return await submitted.reader()
@@ -3285,17 +3312,21 @@ class AsyncConnection(object):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> AsyncSqlQuery:
"""Start executing SQL and return its query handle.
The database from ``connect_async`` is used for unqualified database
references. The namespace defaults to ``["public"]``. Local
connections raise ``NotImplementedError``.
references. The namespace defaults to ``["public"]``. ``parameters``
binds values to ``$1`` / ``$name`` placeholders; see
[DBConnection.execute_query_async][lancedb.db.DBConnection.execute_query_async].
Local connections raise ``NotImplementedError``.
"""
return AsyncSqlQuery(
await self._inner.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=to_parameter_batch(parameters),
)
)
+5 -1
View File
@@ -53,7 +53,7 @@ from lancedb.db import AsyncConnection, DBConnection
from lancedb.job import AsyncJob, Job
from lancedb.listing import AsyncListing, Listing
from lancedb.sql import AsyncQuery as AsyncSqlQuery
from lancedb.sql import QueryDescription
from lancedb.sql import QueryDescription, QueryParameters
from lance_namespace import (
LanceNamespace,
connect as namespace_connect,
@@ -1584,11 +1584,13 @@ class AsyncLanceNamespaceDBConnection:
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> AsyncRecordBatchReader:
"""Execute SQL when supported by the underlying connection."""
return await self._inner.execute_query(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
)
async def execute_query_async(
@@ -1596,6 +1598,7 @@ class AsyncLanceNamespaceDBConnection:
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> AsyncSqlQuery:
"""Start executing SQL when supported by the underlying connection.
@@ -1604,6 +1607,7 @@ class AsyncLanceNamespaceDBConnection:
return await self._inner.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
)
async def describe_query(self, query_id: UUID) -> QueryDescription:
+5 -1
View File
@@ -41,7 +41,7 @@ from ..db import DBConnection, LOOP
from ..functions import FunctionVersion, UdfDefinition
from ..job import AsyncJob, Job
from ..sql import Query as SqlQuery
from ..sql import QueryDescription
from ..sql import QueryDescription, QueryParameters
from ..materialized_view import MaterializedView, MaterializedViewSource, SelectArg
from ..secrets import EnvVarSecret, SecretInfo
from ..view import ViewDescription
@@ -1090,18 +1090,22 @@ class RemoteDBConnection(DBConnection):
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
parameters: Optional[QueryParameters] = None,
) -> SqlQuery:
"""Start executing SQL through this remote connection.
Unqualified tables use this connection's database and the
``["public"]`` namespace by default. Fully qualified table names may
reference other databases available to the same deployment.
``parameters`` binds values to ``$1`` / ``$name`` placeholders; see
[DBConnection.execute_query_async][lancedb.db.DBConnection.execute_query_async].
"""
return SqlQuery(
LOOP.run(
self._conn.execute_query_async(
query,
default_namespace_path=default_namespace_path,
parameters=parameters,
)
)
)
+79 -1
View File
@@ -3,8 +3,10 @@
"""Handles to SQL queries running on a remote database."""
from typing import Any, Mapping, Optional, Sequence, Union
from uuid import UUID
import numpy as np
import pyarrow as pa
from lancedb.background_loop import LOOP
@@ -14,6 +16,82 @@ from .arrow import AsyncRecordBatchReader
QueryDescription = _lancedb.QueryDescription
QueryParameters = Union[pa.RecordBatch, pa.Table, Sequence[Any], Mapping[str, Any]]
"""Values for a statement's ``$1`` / ``$name`` placeholders.
A sequence binds by position (``$1``, ``$2``, ...), a mapping binds by name
(``$name``), and a one-row ``pyarrow.RecordBatch`` or ``pyarrow.Table`` binds
both ways: column ``i`` is ``$<i + 1>`` and, when its name is not a number,
also ``$<name>``.
"""
def _parameter_array(value: Any) -> pa.Array:
"""One parameter as a one-element array of its own type."""
if isinstance(value, pa.Scalar):
return pa.array([value.as_py()], type=value.type)
# A 1-D array is a vector: keep its element type and make it the
# fixed-size list a vector column stores, rather than a list of Python
# floats that would come back as float64.
if isinstance(value, pa.ChunkedArray):
value = value.combine_chunks()
if isinstance(value, np.ndarray):
if value.ndim == 0:
return pa.array(value.reshape(1))
if value.ndim != 1:
raise ValueError(
"a numpy query parameter must be a scalar or a 1-D vector, "
f"got {value.ndim} dimensions"
)
value = pa.array(value)
if isinstance(value, pa.Array):
return pa.FixedSizeListArray.from_arrays(value, len(value))
return pa.array([value])
def to_parameter_batch(
parameters: Optional[QueryParameters],
) -> Optional[pa.RecordBatch]:
"""Turn ``parameters`` into the one-row batch a parameterized query sends.
Scalars keep the type pyarrow infers for them (numpy scalars keep their
dtype), a ``pyarrow.Scalar`` keeps its own type, and a 1-D numpy or
pyarrow array becomes a ``FixedSizeList`` of its element type, which is
what a vector column stores.
"""
if parameters is None:
return None
batch: pa.RecordBatch
if isinstance(parameters, pa.RecordBatch):
batch = parameters
elif isinstance(parameters, pa.Table):
batches = parameters.combine_chunks().to_batches()
batch = (
batches[0]
if len(batches) == 1
else pa.RecordBatch.from_pylist([], schema=parameters.schema)
)
elif isinstance(parameters, Mapping):
batch = pa.RecordBatch.from_arrays(
[_parameter_array(value) for value in parameters.values()],
names=[str(name) for name in parameters.keys()],
)
elif isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)):
batch = pa.RecordBatch.from_arrays(
[_parameter_array(value) for value in parameters],
names=[str(position + 1) for position in range(len(parameters))],
)
else:
raise TypeError(
"query parameters must be a sequence, a mapping, or a one-row "
f"pyarrow RecordBatch or Table, not {type(parameters).__name__}"
)
if batch.num_rows != 1:
raise ValueError(
f"query parameters must be exactly one row, got {batch.num_rows}"
)
return batch
class AsyncQuery:
"""A handle to a submitted SQL query on an asynchronous connection."""
@@ -85,4 +163,4 @@ class Query:
LOOP.run(self._inner.cancel())
__all__ = ["AsyncQuery", "Query", "QueryDescription"]
__all__ = ["AsyncQuery", "Query", "QueryDescription", "QueryParameters"]
+110 -3
View File
@@ -1,8 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import threading
from uuid import UUID
import numpy as np
import pytest
import pyarrow as pa
@@ -11,7 +13,7 @@ from lancedb import _lancedb
from lancedb.arrow import AsyncRecordBatchReader
from lancedb.db import AsyncConnection
from lancedb.remote.db import RemoteDBConnection
from lancedb.sql import AsyncQuery, Query
from lancedb.sql import AsyncQuery, Query, to_parameter_batch
NIL_QUERY_ID = UUID(int=0)
@@ -24,12 +26,20 @@ class FakeNativeQuery:
class FakeNativeConnection:
async def execute_query_async(self, query, *, default_namespace_path=None):
def __init__(self):
self.parameters = []
async def execute_query_async(
self, query, *, default_namespace_path=None, parameters=None
):
self.parameters.append(parameters)
return FakeNativeQuery()
class FakeAsyncConnection:
async def execute_query_async(self, query, *, default_namespace_path=None):
async def execute_query_async(
self, query, *, default_namespace_path=None, parameters=None
):
return AsyncQuery(FakeNativeQuery())
@@ -160,3 +170,100 @@ def test_execute_query_async_rejects_invalid_namespace_components(
remote_connection().execute_query_async(
"SELECT 1", default_namespace_path=default_namespace_path
)
def test_positional_parameters_keep_their_types():
vector = np.array([0.1, 0.2, 0.3], dtype=np.float32)
batch = to_parameter_batch(
[42, 1.5, "a", np.float32(0.25), vector, pa.scalar(7, pa.int16())]
)
assert batch.num_rows == 1
assert batch.schema.names == ["1", "2", "3", "4", "5", "6"]
assert batch.schema.types == [
pa.int64(),
pa.float64(),
pa.string(),
pa.float32(),
pa.list_(pa.float32(), 3),
pa.int16(),
]
sent = batch.column(4).flatten().to_numpy()
assert sent.dtype == np.float32
assert sent.tobytes() == vector.tobytes()
def test_named_parameters_and_batches():
half = pa.array(np.array([1.0, 2.0], dtype=np.float16))
batch = to_parameter_batch({"vector": half, "k": 3})
assert batch.schema.names == ["vector", "k"]
assert batch.schema.field("vector").type == pa.list_(pa.float16(), 2)
row = pa.record_batch({"id": [1]})
assert to_parameter_batch(row) is row
assert to_parameter_batch(pa.table({"id": [1]})).equals(row)
assert to_parameter_batch(None) is None
@pytest.mark.parametrize(
"parameters",
[pa.record_batch({"id": [1, 2]}), pa.table({"id": pa.array([], pa.int64())})],
)
def test_parameters_are_exactly_one_row(parameters):
with pytest.raises(ValueError, match="exactly one row"):
to_parameter_batch(parameters)
def test_parameters_must_be_a_collection():
with pytest.raises(TypeError, match="query parameters"):
to_parameter_batch("SELECT 1")
@pytest.mark.asyncio
async def test_async_execute_query_sends_parameters_as_a_batch():
native = FakeNativeConnection()
connection = AsyncConnection(native)
await connection.execute_query("SELECT $1", parameters=[1])
await connection.execute_query_async("SELECT 1")
sent, unparameterized = native.parameters
assert sent.equals(pa.record_batch({"1": [1]}))
assert unparameterized is None
def test_parameters_reach_the_server_as_the_values_that_were_sent():
"""A round trip through the bindings against a Flight server that answers
each exchange with the parameter row it received."""
flight = pytest.importorskip("pyarrow.flight")
class EchoServer(flight.FlightServerBase):
def __init__(self):
super().__init__("grpc://127.0.0.1:0")
self.commands = []
self.lock = threading.Lock()
def do_exchange(self, context, descriptor, reader, writer):
parameters = reader.read_all()
with self.lock:
self.commands.append(descriptor.command)
writer.begin(parameters.schema)
writer.write_table(parameters)
writer.close()
vector = np.array([0.1, 1e-45, -0.0], dtype=np.float32)
with EchoServer() as server:
db = lancedb.connect(
"db://analytics",
api_key="test-key",
host_override="http://localhost:10024",
sql_host_override=f"grpc://127.0.0.1:{server.port}",
)
echoed = db.execute_query(
"SELECT echo", parameters={"vector": vector, "k": 10}
).read_all()
(command,) = server.commands
assert b"CommandStatementQuery" in command
assert b"SELECT echo" in command
assert echoed.schema.field("vector").type == pa.list_(pa.float32(), 3)
received = echoed.column("vector").combine_chunks().flatten().to_numpy()
assert received.view(np.uint32).tolist() == vector.view(np.uint32).tolist()
assert echoed.column("k").to_pylist() == [10]
+8 -3
View File
@@ -14,9 +14,10 @@ use crate::{
table::Table,
};
use arrow::{
array::RecordBatch,
datatypes::Schema,
ffi_stream::ArrowArrayStreamReader,
pyarrow::{FromPyArrow, ToPyArrow},
pyarrow::{FromPyArrow, PyArrowType, ToPyArrow},
};
use lancedb::{
connection::Connection as LanceConnection,
@@ -148,18 +149,22 @@ impl Connection {
self.get_inner().map(|inner| inner.uri().to_string())
}
#[pyo3(signature = (query, *, default_namespace_path=None))]
#[pyo3(signature = (query, *, default_namespace_path=None, parameters=None))]
pub fn execute_query_async<'a>(
self_: PyRef<'a, Self>,
query: String,
default_namespace_path: Option<Bound<'_, PyAny>>,
parameters: Option<PyArrowType<RecordBatch>>,
) -> PyResult<Bound<'a, PyAny>> {
let inner = self_.get_inner()?.clone();
let default_namespace_path = parse_default_namespace_path(default_namespace_path)?;
future_into_py(self_.py(), async move {
let operation = inner
let mut operation = inner
.execute_query_async(query)
.default_namespace_path(default_namespace_path);
if let Some(PyArrowType(parameters)) = parameters {
operation = operation.parameters(parameters);
}
operation
.execute()
.await
+59 -12
View File
@@ -23,8 +23,8 @@ use crate::connection::create_table::CreateTableBuilder;
use crate::data::scannable::Scannable;
use crate::database::listing::ListingDatabase;
use crate::database::{
CloneTableRequest, Database, DatabaseOptions, JobInfo, OpenTableRequest, PauseJobStatus,
ReadConsistency, ResumeJobStatus, TableNamesRequest,
CloneTableRequest, Database, DatabaseOptions, ExecuteQueryRequest, JobInfo, OpenTableRequest,
PauseJobStatus, ReadConsistency, ResumeJobStatus, TableNamesRequest,
};
use crate::embeddings::{EmbeddingRegistry, MemoryRegistry};
use crate::error::{Error, Result};
@@ -335,16 +335,14 @@ pub struct CloneTableBuilder {
/// Builder for asynchronously executing a SQL statement on a remote database.
pub struct ExecuteQueryAsyncBuilder {
parent: Arc<dyn Database>,
query: String,
default_namespace_path: Vec<String>,
request: ExecuteQueryRequest,
}
impl ExecuteQueryAsyncBuilder {
fn new(parent: Arc<dyn Database>, query: String) -> Self {
Self {
parent,
query,
default_namespace_path: vec!["public".to_string()],
request: ExecuteQueryRequest::new(query),
}
}
@@ -357,15 +355,63 @@ impl ExecuteQueryAsyncBuilder {
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.default_namespace_path = path.into_iter().map(Into::into).collect();
self.request.default_namespace_path = path.into_iter().map(Into::into).collect();
self
}
/// Bind values to the statement's placeholders.
///
/// `parameters` is a single-row batch. Column `i` binds `$<i + 1>`, and a
/// column whose name is not a number also binds `$<name>`, so
/// `WHERE id = $1` and `WHERE id = $id` both work. The values travel as
/// Arrow rather than as SQL text, so a float keeps its exact bits and type
/// and a vector stays a compact `FixedSizeList`.
///
/// Every placeholder needs a value and every parameter must be used by a
/// placeholder. Parameters are supported in queries (`SELECT`, and
/// `EXPLAIN` of one), not in DDL or DML, nor in table-function arguments.
/// Do not follow a named placeholder directly with `$`: `$a$` starts a
/// dollar-quoted string.
///
/// A parameterized statement runs on the call that returns its rows rather
/// than detached from it: cancelling the query, or dropping its handle
/// before reading it, stops the statement on the server. Its status follows
/// that stream too -- it reports `Running` until the reader has received
/// every row -- so open the reader directly rather than waiting for
/// `Finished`.
///
/// # Example
///
/// ```no_run
/// # use std::sync::Arc;
/// # use arrow_array::{ArrayRef, FixedSizeListArray, Int64Array, RecordBatch};
/// # use arrow_array::types::Float32Type;
/// # async fn query(db: &lancedb::Connection) -> lancedb::Result<()> {
/// let vector = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
/// [Some([0.1_f32, 0.2, 0.3].map(Some))],
/// 3,
/// );
/// let parameters = RecordBatch::try_from_iter([
/// ("vector", Arc::new(vector) as ArrayRef),
/// ("k", Arc::new(Int64Array::from(vec![10])) as ArrayRef),
/// ])?;
/// let query = db
/// .execute_query_async("SELECT id FROM docs ORDER BY distance(vector, $vector) LIMIT $k")
/// .parameters(parameters)
/// .execute()
/// .await?;
/// let rows = query.reader().await?;
/// # Ok(())
/// # }
/// ```
pub fn parameters(mut self, parameters: RecordBatch) -> Self {
self.request.parameters = Some(parameters);
self
}
/// Start the statement and return its asynchronous query handle.
pub async fn execute(self) -> Result<crate::sql::Query> {
self.parent
.execute_query_async(&self.query, &self.default_namespace_path)
.await
self.parent.execute_query_async(self.request).await
}
}
@@ -456,8 +502,9 @@ impl Connection {
///
/// The query can reference tables in other databases with SQL dot notation.
/// Use [`ExecuteQueryAsyncBuilder::default_namespace_path`] to avoid qualifying
/// tables in the default namespace. Local connections return
/// [`Error::NotSupported`].
/// tables in the default namespace, and [`ExecuteQueryAsyncBuilder::parameters`]
/// to bind Arrow values to `$1` / `$name` placeholders instead of writing them
/// into the SQL text. Local connections return [`Error::NotSupported`].
///
/// # Example
///
+28 -2
View File
@@ -18,6 +18,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use arrow_array::RecordBatch;
use lance::dataset::ReadParams;
use lance_namespace::LanceNamespace;
use lance_namespace::models::{
@@ -191,6 +192,32 @@ impl CloneTableRequest {
}
}
/// A request to start a SQL statement on a remote database.
#[derive(Clone, Debug)]
pub struct ExecuteQueryRequest {
/// The SQL text.
pub query: String,
/// The namespace unqualified table names resolve in. Empty is `public`,
/// the SQL name of the root namespace.
pub default_namespace_path: Vec<String>,
/// Values for the statement's placeholders, as a single-row batch.
///
/// Column `i` binds `$<i + 1>`, and a column whose name is not a number
/// also binds `$<name>`. See
/// [`ExecuteQueryAsyncBuilder::parameters`](crate::connection::ExecuteQueryAsyncBuilder::parameters).
pub parameters: Option<RecordBatch>,
}
impl ExecuteQueryRequest {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
default_namespace_path: vec!["public".to_string()],
parameters: None,
}
}
}
/// How long until a change is reflected from one Table instance to another
///
/// Tables are always internally consistent. If a write method is called on
@@ -555,8 +582,7 @@ pub trait Database:
/// Start executing a SQL statement on a remote database.
async fn execute_query_async(
&self,
_query: &str,
_default_namespace_path: &[String],
_request: ExecuteQueryRequest,
) -> Result<crate::sql::Query> {
Err(crate::error::Error::NotSupported {
message: "SQL is not supported by this database".to_string(),
+4 -8
View File
@@ -20,8 +20,8 @@ use lance_namespace::models::{
use crate::Error;
use crate::database::{
CloneTableRequest, CreateTableMode, CreateTableRequest, Database, DatabaseOptions, JobInfo,
OpenTableRequest, ReadConsistency, TableNamesRequest,
CloneTableRequest, CreateTableMode, CreateTableRequest, Database, DatabaseOptions,
ExecuteQueryRequest, JobInfo, OpenTableRequest, ReadConsistency, TableNamesRequest,
};
use crate::error::Result;
use crate::function::{
@@ -1329,18 +1329,14 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
})
}
async fn execute_query_async(
&self,
query: &str,
default_namespace_path: &[String],
) -> Result<crate::sql::Query> {
async fn execute_query_async(&self, request: ExecuteQueryRequest) -> Result<crate::sql::Query> {
let client = self
.sql_client
.as_ref()
.ok_or_else(|| Error::NotSupported {
message: "SQL is unavailable for this remote database client".to_string(),
})?;
client.submit(query, default_namespace_path).await
client.execute(request).await
}
async fn describe_query(&self, query_id: uuid::Uuid) -> Result<crate::sql::QueryDescription> {
+89 -5
View File
@@ -27,12 +27,17 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity
use uuid::Uuid;
use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream};
use crate::database::ExecuteQueryRequest;
use crate::error::{Error, Result};
use crate::job::Job;
use crate::remote::client::{ClientConfig, TlsConfig};
use crate::remote::retry::ResolvedRetryConfig;
use crate::sql::{Query, QueryDescription, QueryHandle, QueryStatus};
mod exchange;
use exchange::{ExchangeQuery, ExchangeQueryHandle};
const DEFAULT_SQL_PORT: u16 = 10025;
const DEFAULT_SQL_TLS_PORT: u16 = 10026;
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(120);
@@ -185,6 +190,23 @@ impl SqlClient {
}
}
pub(super) async fn execute(&self, request: ExecuteQueryRequest) -> Result<Query> {
match request.parameters {
Some(parameters) => {
self.submit_with_parameters(
&request.query,
parameters,
&request.default_namespace_path,
)
.await
}
None => {
self.submit(&request.query, &request.default_namespace_path)
.await
}
}
}
pub(super) async fn submit(
&self,
query: &str,
@@ -206,7 +228,8 @@ impl SqlClient {
default_namespace_path.to_vec(),
poll_info,
)?);
self.queries.insert(query_id, query.clone());
self.queries
.insert(query_id, RegisteredQuery::Polled(query.clone()));
Ok(Query::new(Arc::new(RemoteQueryHandle::new(query))))
})
.await
@@ -248,6 +271,35 @@ impl SqlClient {
})
}
/// Values cannot ride on a poll, so a statement that has them is sent on
/// its own exchange instead.
pub(super) async fn submit_with_parameters(
&self,
query: &str,
parameters: RecordBatch,
default_namespace_path: &[String],
) -> Result<Query> {
let timeout = self.inner.overall_timeout()?;
with_overall_timeout(timeout, "SQL query submission", async {
validate_namespace_path(default_namespace_path)?;
let query_id = Uuid::now_v7();
let query = Arc::new(
ExchangeQuery::open(
query_id,
self.inner.clone(),
query,
parameters,
default_namespace_path,
)
.await?,
);
self.queries
.insert(query_id, RegisteredQuery::Exchanged(query.clone()));
Ok(Query::new(Arc::new(ExchangeQueryHandle::new(query))))
})
.await
}
pub(super) async fn describe(&self, query_id: Uuid) -> Result<QueryDescription> {
let query = self
.queries
@@ -530,8 +582,40 @@ impl SqlClientInner {
}
}
/// A submitted query as the connection remembers it.
#[derive(Clone)]
enum RegisteredQuery {
Polled(Arc<RemoteQuery>),
Exchanged(Arc<ExchangeQuery>),
}
impl RegisteredQuery {
fn touch(&self) {
match self {
Self::Polled(query) => query.touch(),
Self::Exchanged(query) => query.touch(),
}
}
/// Whether the registry may forget this query. It is abandoned when the
/// registry holds the only reference to it.
fn registry_expired(&self) -> bool {
match self {
Self::Polled(query) => query.registry_expired(Arc::strong_count(query) == 1),
Self::Exchanged(query) => query.registry_expired(Arc::strong_count(query) == 1),
}
}
async fn describe(&self) -> Result<QueryDescription> {
match self {
Self::Polled(query) => query.describe().await,
Self::Exchanged(query) => query.describe(),
}
}
}
struct QueryRegistry {
queries: StdMutex<HashMap<Uuid, Arc<RemoteQuery>>>,
queries: StdMutex<HashMap<Uuid, RegisteredQuery>>,
}
impl QueryRegistry {
@@ -541,12 +625,12 @@ impl QueryRegistry {
}
}
fn insert(&self, id: Uuid, query: Arc<RemoteQuery>) {
fn insert(&self, id: Uuid, query: RegisteredQuery) {
self.remove_expired();
self.queries.lock().unwrap().insert(id, query);
}
fn get(&self, id: Uuid) -> Option<Arc<RemoteQuery>> {
fn get(&self, id: Uuid) -> Option<RegisteredQuery> {
self.remove_expired();
let query = self.queries.lock().unwrap().get(&id).cloned();
if let Some(query) = &query {
@@ -559,7 +643,7 @@ impl QueryRegistry {
self.queries
.lock()
.unwrap()
.retain(|_, query| !query.registry_expired(Arc::strong_count(query) == 1));
.retain(|_, query| !query.registry_expired());
}
}
+435
View File
@@ -0,0 +1,435 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! SQL statements sent with parameters.
//!
//! A statement without parameters is polled: `PollFlightInfo` starts it
//! detached from the connection and `DoGet` reads its endpoints. Neither call
//! carries Arrow data, so a statement's values would have to be spelled into
//! its text. A statement with parameters is sent on one `DoExchange` instead:
//! the command and the parameter schema, then one row, and the result rows
//! come back on the same call. The call is the statement's lifetime, so
//! cancelling it -- or dropping the handle before reading -- stops the
//! statement on the server.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::Instant;
use arrow_array::RecordBatch;
use arrow_flight::FlightDescriptor;
use arrow_flight::encode::FlightDataEncoderBuilder;
use arrow_flight::sql::{CommandStatementQuery, ProstMessageExt};
use arrow_schema::SchemaRef;
use prost::Message;
use tokio::sync::{Notify, mpsc};
use uuid::Uuid;
use super::{
ABANDONED_QUERY_RETENTION, DEFAULT_READ_TIMEOUT, ResultEndpointStream, ResultStartGuard,
SqlClientInner, TERMINAL_QUERY_RETENTION, resolve_timeout, sql_error, with_overall_timeout,
};
use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream};
use crate::error::{Error, Result};
use crate::sql::{QueryDescription, QueryHandle, QueryStatus};
#[derive(Clone, Debug, PartialEq, Eq)]
enum Lifecycle {
Running,
/// Every row has been received.
Finished,
Cancelled,
Failed(String),
}
/// A failed statement, keeping the message `describe` reports it with -- not
/// `Runtime error: …` again when `describe` wraps it in a runtime error.
fn failure(error: &Error) -> Lifecycle {
Lifecycle::Failed(match error {
Error::Runtime { message } => message.clone(),
other => other.to_string(),
})
}
/// A statement running on its own `DoExchange` call.
pub(super) struct ExchangeQuery {
id: Uuid,
client: Arc<SqlClientInner>,
/// The call's rows, until a reader takes them or a cancellation drops them.
response: StdMutex<Option<ResultEndpointStream>>,
lifecycle: StdMutex<Lifecycle>,
cancelled: Notify,
terminal_at: OnceLock<Instant>,
last_accessed: StdMutex<Instant>,
}
struct PreparedExchange {
schema: SchemaRef,
first: Option<RecordBatch>,
rest: Option<ResultEndpointStream>,
}
/// The call's rows while a reader waits for their first batch.
///
/// A reader whose future is dropped mid-wait -- its caller gave up, or its
/// task was cancelled -- puts them back, so the next reader finds the query as
/// this one did. A query cancelled meanwhile has its rows dropped instead,
/// which ends the call.
struct Borrowed<'a> {
query: &'a ExchangeQuery,
rows: Option<ResultEndpointStream>,
}
impl Borrowed<'_> {
fn get(&mut self) -> &mut ResultEndpointStream {
self.rows.as_mut().expect("rows are borrowed until kept")
}
fn keep(mut self) -> ResultEndpointStream {
self.rows.take().expect("rows are borrowed until kept")
}
}
impl Drop for Borrowed<'_> {
fn drop(&mut self) {
let Some(rows) = self.rows.take() else {
return;
};
// Checked under the response lock: `cancel` settles first and takes
// the response after, so rows put back here are never missed by it.
let mut response = self.query.response.lock().unwrap();
if *self.query.lifecycle.lock().unwrap() == Lifecycle::Running {
*response = Some(rows);
}
}
}
impl ExchangeQuery {
/// Send the statement and its parameters, returning once the server has
/// planned it: a statement it refuses is refused here, as a polled one is
/// on its first poll.
pub(super) async fn open(
id: Uuid,
client: Arc<SqlClientInner>,
query: &str,
parameters: RecordBatch,
default_namespace_path: &[String],
) -> Result<Self> {
if parameters.num_rows() != 1 {
return Err(Error::InvalidInput {
message: format!(
"SQL query parameters must be exactly one row, got {}",
parameters.num_rows()
),
});
}
let request_id = Uuid::new_v4().to_string();
let read_timeout = resolve_timeout(
client.client_config.timeout_config.read_timeout,
"LANCE_CLIENT_READ_TIMEOUT",
Some(DEFAULT_READ_TIMEOUT),
)?
.unwrap();
let descriptor = FlightDescriptor::new_cmd(
CommandStatementQuery {
query: query.to_string(),
transaction_id: None,
}
.as_any()
.encode_to_vec(),
);
// The descriptor rides on the schema message, and the row follows it.
let request = FlightDataEncoderBuilder::new()
.with_flight_descriptor(Some(descriptor))
.with_schema(parameters.schema())
.build(futures::stream::iter([Ok(parameters)]));
let mut flight = client
.client_with_headers(default_namespace_path, &request_id)
.await?;
let stream = tokio::time::timeout(read_timeout, flight.do_exchange(request))
.await
.map_err(|_| sql_error(&request_id, "SQL query submission timed out"))?
.map_err(|err| sql_error(&request_id, err))?;
Ok(Self {
id,
client,
response: StdMutex::new(Some(ResultEndpointStream {
stream,
request_id,
read_timeout,
})),
lifecycle: StdMutex::new(Lifecycle::Running),
cancelled: Notify::new(),
terminal_at: OnceLock::new(),
last_accessed: StdMutex::new(Instant::now()),
})
}
pub(super) fn touch(&self) {
*self.last_accessed.lock().unwrap() = Instant::now();
}
pub(super) fn registry_expired(&self, abandoned: bool) -> bool {
if let Some(finished) = self.terminal_at.get() {
return finished.elapsed() >= TERMINAL_QUERY_RETENTION;
}
abandoned && self.last_accessed.lock().unwrap().elapsed() >= ABANDONED_QUERY_RETENTION
}
pub(super) fn describe(&self) -> Result<QueryDescription> {
self.touch();
let status = match &*self.lifecycle.lock().unwrap() {
Lifecycle::Running => QueryStatus::Running,
Lifecycle::Finished => QueryStatus::Finished,
Lifecycle::Cancelled => QueryStatus::Cancelled,
Lifecycle::Failed(message) => {
return Err(Error::Runtime {
message: message.clone(),
});
}
};
Ok(QueryDescription {
id: self.id,
status,
progress: (status == QueryStatus::Finished).then_some(1.0),
expires_at: None,
})
}
/// Stop the statement. Idempotent, and a no-op once it has ended.
pub(super) fn cancel(&self) {
self.touch();
let settled = self.settle(Lifecycle::Cancelled);
// Unread rows are dropped here, which cancels the call; a query that
// already ended has none. A reader's stream is dropped by its task
// when it sees the cancellation.
self.response.lock().unwrap().take();
if settled {
self.cancelled.notify_waiters();
}
}
/// Move a running statement to `state`. `true` if this call did.
fn settle(&self, state: Lifecycle) -> bool {
let mut lifecycle = self.lifecycle.lock().unwrap();
if *lifecycle != Lifecycle::Running {
return false;
}
*lifecycle = state;
drop(lifecycle);
let _ = self.terminal_at.set(Instant::now());
true
}
fn is_cancelled(&self) -> bool {
*self.lifecycle.lock().unwrap() == Lifecycle::Cancelled
}
fn cancelled_error(&self) -> Error {
Error::JobCancelled {
job_id: Some(self.id.to_string()),
}
}
async fn wait_for_cancellation(&self) {
loop {
let cancelled = self.cancelled.notified();
if self.is_cancelled() {
return;
}
cancelled.await;
}
}
/// Take the rows and wait for the first batch, which carries the schema.
async fn prepare(&self) -> Result<PreparedExchange> {
let Some(rows) = self.response.lock().unwrap().take() else {
// Taken by an earlier attempt that failed, or dropped by a cancel.
return Err(match &*self.lifecycle.lock().unwrap() {
Lifecycle::Failed(message) => Error::Runtime {
message: message.clone(),
},
_ => self.cancelled_error(),
});
};
let mut rows = Borrowed {
query: self,
rows: Some(rows),
};
let first = tokio::select! {
biased;
_ = self.wait_for_cancellation() => return Err(self.cancelled_error()),
result = rows.get().next_batch() => result,
};
// Read or failed, the rows are this attempt's now: a failed read is
// not worth putting back.
let rows = rows.keep();
let first = first?;
let schema = first
.as_ref()
.map(RecordBatch::schema)
.or_else(|| rows.stream.schema().cloned())
.ok_or_else(|| Error::Runtime {
message: "SQL result stream did not include a schema".to_string(),
})?;
if first.is_none() {
self.settle(Lifecycle::Finished);
}
Ok(PreparedExchange {
schema,
rest: first.is_some().then_some(rows),
first,
})
}
async fn run(
self: Arc<Self>,
mut prepared: PreparedExchange,
sender: mpsc::Sender<Result<RecordBatch>>,
) -> Result<()> {
if let Some(batch) = prepared.first.take()
&& !self.send(&sender, batch).await?
{
return Ok(());
}
let Some(mut rows) = prepared.rest.take() else {
return Ok(());
};
loop {
let batch = tokio::select! {
biased;
// The reader was dropped: the rows go with this task, which
// ends the call.
_ = sender.closed() => {
self.settle(Lifecycle::Cancelled);
return Ok(());
}
_ = self.wait_for_cancellation() => return Err(self.cancelled_error()),
result = rows.next_batch() => result,
};
match batch {
Ok(Some(batch)) => {
if !self.send(&sender, batch).await? {
return Ok(());
}
}
Ok(None) => {
self.settle(Lifecycle::Finished);
return Ok(());
}
Err(error) => {
self.settle(failure(&error));
return Err(error);
}
}
}
}
async fn send(
&self,
sender: &mpsc::Sender<Result<RecordBatch>>,
batch: RecordBatch,
) -> Result<bool> {
tokio::select! {
biased;
_ = self.wait_for_cancellation() => Err(self.cancelled_error()),
result = sender.send(Ok(batch)) => {
if result.is_err() {
self.settle(Lifecycle::Cancelled);
}
Ok(result.is_ok())
}
}
}
}
pub(super) struct ExchangeQueryHandle {
query: Arc<ExchangeQuery>,
result_started: AtomicBool,
}
impl ExchangeQueryHandle {
pub(super) fn new(query: Arc<ExchangeQuery>) -> Self {
Self {
query,
result_started: AtomicBool::new(false),
}
}
}
impl Drop for ExchangeQueryHandle {
// Rows nobody took are rows nobody will read. Keeping the call open would
// hold the statement running on the server, stalled on flow control,
// until the registry forgot it.
fn drop(&mut self) {
if self.query.response.lock().unwrap().is_some() {
self.query.cancel();
}
}
}
#[async_trait::async_trait]
impl QueryHandle for ExchangeQueryHandle {
fn id(&self) -> Uuid {
self.query.touch();
self.query.id
}
async fn describe(&self) -> Result<QueryDescription> {
self.query.describe()
}
async fn reader(&self) -> Result<SendableRecordBatchStream> {
let timeout = self.query.client.overall_timeout()?;
if self
.result_started
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return Err(Error::Runtime {
message: "SQL query results can only be consumed once".to_string(),
});
}
let result_start = ResultStartGuard::new(&self.result_started);
self.query.touch();
let started = Instant::now();
let prepared =
match with_overall_timeout(timeout, "SQL query result", self.query.prepare()).await {
Ok(prepared) => prepared,
Err(error) => {
// A failed read or the timeout ends the query, and with it
// the call, including rows a timed-out wait put back.
self.query.settle(failure(&error));
self.query.response.lock().unwrap().take();
return Err(error);
}
};
let remaining_timeout = timeout.map(|timeout| timeout.saturating_sub(started.elapsed()));
let schema = prepared.schema.clone();
let (sender, receiver) = mpsc::channel(2);
let error_sender = sender.clone();
let query = self.query.clone();
tokio::spawn(async move {
let result = with_overall_timeout(
remaining_timeout,
"SQL query result",
query.clone().run(prepared, sender),
)
.await;
if let Err(error) = result {
query.settle(failure(&error));
let _ = error_sender.send(Err(error)).await;
}
});
let stream = futures::stream::unfold(receiver, |mut receiver| async move {
receiver.recv().await.map(|item| (item, receiver))
});
result_start.commit();
Ok(Box::pin(SimpleRecordBatchStream::new(stream, schema)))
}
async fn cancel(&self) -> Result<()> {
self.query.cancel();
Ok(())
}
}
+387 -27
View File
@@ -4,7 +4,8 @@
use std::sync::atomic::AtomicUsize;
use arrow_array::builder::StringDictionaryBuilder;
use arrow_array::{Array, Int64Array, StringArray, types::Int32Type};
use arrow_array::types::{Float32Type, Int32Type};
use arrow_array::{Array, ArrayRef, FixedSizeListArray, Float32Array, Int64Array, StringArray};
use arrow_flight::encode::FlightDataEncoderBuilder;
use arrow_flight::flight_service_server::{FlightService, FlightServiceServer};
use arrow_flight::sql::{Any, CommandStatementQuery};
@@ -15,6 +16,7 @@ use arrow_flight::{
use arrow_schema::{DataType, Field, Schema};
use futures::stream::BoxStream;
use futures::{StreamExt, TryStreamExt};
use tonic::metadata::MetadataMap;
use tonic::{Request, Response, Status, Streaming};
use super::*;
@@ -63,6 +65,9 @@ struct CapturedHeaders {
database_prefix: String,
}
/// Every exchange's statement and the parameter row it arrived with.
type ReceivedExchanges = Arc<std::sync::Mutex<Vec<(String, Option<RecordBatch>)>>>;
#[derive(Clone)]
struct TestSqlService {
query_count: Arc<AtomicUsize>,
@@ -77,11 +82,46 @@ struct TestSqlService {
transient_poll_failures: Arc<AtomicUsize>,
headers: Arc<std::sync::Mutex<Vec<CapturedHeaders>>>,
gate_refresh_release: Arc<Notify>,
exchanges: ReceivedExchanges,
/// How many exchange responses have been dropped, finished or not.
exchange_drops: Arc<AtomicUsize>,
result: RecordBatch,
large_result: RecordBatch,
dictionary_result: RecordBatch,
}
/// Counts a response stream being dropped, which is how a server sees a call
/// end -- by the client cancelling it or by the rows running out.
struct DropSignal(Arc<AtomicUsize>);
impl Drop for DropSignal {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
impl TestSqlService {
fn capture_headers(&self, metadata: &MetadataMap) {
let header = |name| {
metadata
.get(name)
.and_then(|value| value.to_str().ok())
.unwrap()
.to_string()
};
self.headers.lock().unwrap().push(CapturedHeaders {
database: header("database"),
namespace_path: header("namespace-path"),
request_id: header("x-request-id"),
api_key: header("x-api-key"),
database_prefix: metadata
.get("x-lancedb-database-prefix")
.map(|value| value.to_str().unwrap().to_string())
.unwrap_or_default(),
});
}
}
impl Default for TestSqlService {
fn default() -> Self {
let schema = Arc::new(Schema::new(vec![Field::new(
@@ -126,6 +166,8 @@ impl Default for TestSqlService {
transient_poll_failures: Arc::new(AtomicUsize::new(0)),
headers: Arc::new(std::sync::Mutex::new(Vec::new())),
gate_refresh_release: Arc::new(Notify::new()),
exchanges: Arc::new(std::sync::Mutex::new(Vec::new())),
exchange_drops: Arc::new(AtomicUsize::new(0)),
result,
large_result,
dictionary_result,
@@ -168,24 +210,7 @@ impl FlightService for TestSqlService {
&self,
request: Request<arrow_flight::FlightDescriptor>,
) -> std::result::Result<Response<PollInfo>, Status> {
let metadata = request.metadata();
let header = |name| {
metadata
.get(name)
.and_then(|value| value.to_str().ok())
.unwrap()
.to_string()
};
self.headers.lock().unwrap().push(CapturedHeaders {
database: header("database"),
namespace_path: header("namespace-path"),
request_id: header("x-request-id"),
api_key: header("x-api-key"),
database_prefix: metadata
.get("x-lancedb-database-prefix")
.map(|value| value.to_str().unwrap().to_string())
.unwrap_or_default(),
});
self.capture_headers(request.metadata());
let command = Any::decode(request.get_ref().cmd.as_ref())
.ok()
@@ -394,11 +419,336 @@ impl FlightService for TestSqlService {
Err(Status::unimplemented("list_actions"))
}
/// Answers with the parameter row it was sent, so a test can compare what
/// came back with what went out.
async fn do_exchange(
&self,
_request: Request<Streaming<FlightData>>,
request: Request<Streaming<FlightData>>,
) -> std::result::Result<Response<Self::DoExchangeStream>, Status> {
Err(Status::unimplemented("do_exchange"))
self.capture_headers(request.metadata());
let mut messages = request.into_inner();
let first = messages
.message()
.await?
.ok_or_else(|| Status::invalid_argument("empty exchange"))?;
let query = first
.flight_descriptor
.as_ref()
.and_then(|descriptor| Any::decode(descriptor.cmd.as_ref()).ok())
.and_then(|any| any.unpack::<CommandStatementQuery>().ok().flatten())
.map(|command| command.query)
.ok_or_else(|| Status::invalid_argument("exchange without a statement"))?;
let data = futures::stream::once(async move { Ok(first) })
.chain(messages)
.map_err(|status| FlightError::Tonic(Box::new(status)));
let parameters = FlightRecordBatchStream::new_from_flight_data(data)
.try_next()
.await
.map_err(|error| Status::invalid_argument(error.to_string()))?;
self.exchanges
.lock()
.unwrap()
.push((query.clone(), parameters.clone()));
if query == "SELECT exchange refused" {
return Err(Status::invalid_argument(
"parameter 1 is not referenced by the statement",
));
}
if query == "SELECT exchange stall" {
return Ok(Response::new(futures::stream::pending().boxed()));
}
let rows =
parameters.ok_or_else(|| Status::invalid_argument("exchange without parameters"))?;
let schema = rows.schema();
let rest: BoxStream<'static, arrow_flight::error::Result<RecordBatch>> =
if query == "SELECT exchange slow" {
futures::stream::pending().boxed()
} else {
futures::stream::empty().boxed()
};
let delay = if query == "SELECT exchange delayed" {
Duration::from_millis(300)
} else {
Duration::ZERO
};
let dropped = DropSignal(self.exchange_drops.clone());
let stream = FlightDataEncoderBuilder::new()
.with_schema(schema)
.build(
futures::stream::once(async move {
tokio::time::sleep(delay).await;
Ok(rows)
})
.chain(rest),
)
.map_err(Status::from)
.map(move |message| {
let _dropped = &dropped;
message
});
Ok(Response::new(Box::pin(stream)))
}
}
fn parameter_row() -> RecordBatch {
let vector = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
[Some([0.1_f32, 1e-45, -0.0].map(Some))],
3,
);
RecordBatch::try_from_iter([
("vector", Arc::new(vector) as ArrayRef),
("k", Arc::new(Int64Array::from(vec![10])) as ArrayRef),
])
.unwrap()
}
fn parameterized(query: &str, parameters: RecordBatch) -> ExecuteQueryRequest {
ExecuteQueryRequest {
parameters: Some(parameters),
..ExecuteQueryRequest::new(query)
}
}
async fn start_sql_service(
service: TestSqlService,
client_config: ClientConfig,
) -> (SqlClient, tokio::sync::oneshot::Sender<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let incoming = futures::stream::try_unfold(listener, |listener| async {
let (socket, _) = listener.accept().await?;
Ok::<_, std::io::Error>(Some((socket, listener)))
});
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
tokio::spawn(
tonic::transport::Server::builder()
.add_service(FlightServiceServer::new(service))
.serve_with_incoming_shutdown(incoming, async {
let _ = shutdown_rx.await;
}),
);
let client = SqlClient::new(
"analytics".to_string(),
None,
"test-key".to_string(),
None,
Some(format!("grpc://{address}")),
client_config,
);
(client, shutdown_tx)
}
async fn wait_for(count: &AtomicUsize, expected: usize, what: &str) {
tokio::time::timeout(Duration::from_secs(5), async {
while count.load(Ordering::SeqCst) < expected {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap_or_else(|_| panic!("{what}"));
}
/// The statement and its row go out on one exchange -- no poll, no ticket --
/// and the row arrives as exactly the bytes that were sent.
#[tokio::test]
async fn parameters_travel_on_one_exchange() {
let service = TestSqlService::default();
let exchanges = service.exchanges.clone();
let query_count = service.query_count.clone();
let do_get_count = service.do_get_count.clone();
let headers = service.headers.clone();
let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await;
let parameters = parameter_row();
let query = client
.execute(ExecuteQueryRequest {
default_namespace_path: vec!["events".to_string(), "raw".to_string()],
..parameterized("SELECT echo", parameters.clone())
})
.await
.unwrap();
assert_eq!(query.describe().await.unwrap().status, QueryStatus::Running);
let rows = collect_result(&query).await.unwrap();
assert_eq!(rows, vec![parameters.clone()]);
let echoed = rows[0]
.column(0)
.as_any()
.downcast_ref::<FixedSizeListArray>()
.unwrap()
.values()
.as_any()
.downcast_ref::<Float32Array>()
.unwrap()
.values()
.iter()
.map(|value| value.to_bits())
.collect::<Vec<_>>();
assert_eq!(
echoed,
[0.1_f32, 1e-45, -0.0].map(f32::to_bits),
"every float keeps its exact bits, the subnormal and the sign of zero included"
);
let finished = query.describe().await.unwrap();
assert_eq!(finished.status, QueryStatus::Finished);
assert_eq!(finished.progress, Some(1.0));
assert_eq!(
client.describe(query.id()).await.unwrap().status,
QueryStatus::Finished
);
assert!(query.reader().await.is_err());
assert_eq!(
*exchanges.lock().unwrap(),
vec![("SELECT echo".to_string(), Some(parameters))]
);
assert_eq!(query_count.load(Ordering::SeqCst), 0);
assert_eq!(do_get_count.load(Ordering::SeqCst), 0);
let headers = headers.lock().unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].database, "analytics");
assert_eq!(headers[0].namespace_path, "events$raw");
assert_eq!(headers[0].api_key, "test-key");
}
/// The server plans before it answers, so a refusal is the submission's.
#[tokio::test]
async fn a_refused_statement_fails_its_submission() {
let (client, _shutdown) =
start_sql_service(TestSqlService::default(), ClientConfig::default()).await;
let refused = client
.execute(parameterized("SELECT exchange refused", parameter_row()))
.await
.unwrap_err();
assert!(
refused
.to_string()
.contains("not referenced by the statement"),
"{refused}"
);
let two_rows =
RecordBatch::try_from_iter([("k", Arc::new(Int64Array::from(vec![1, 2])) as ArrayRef)])
.unwrap();
assert!(matches!(
client.execute(parameterized("SELECT echo", two_rows)).await,
Err(Error::InvalidInput { .. })
));
}
/// The call is the statement's lifetime: cancelling a read, or dropping a
/// handle nobody read, ends the call and so the work behind it.
#[tokio::test]
async fn cancelling_or_abandoning_an_exchange_ends_the_call() {
let service = TestSqlService::default();
let drops = service.exchange_drops.clone();
let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await;
let reading = client
.execute(parameterized("SELECT exchange slow", parameter_row()))
.await
.unwrap();
let mut rows = reading.reader().await.unwrap();
assert_eq!(rows.try_next().await.unwrap().unwrap(), parameter_row());
reading.cancel().await.unwrap();
assert!(matches!(
tokio::time::timeout(Duration::from_secs(5), rows.try_next())
.await
.expect("cancellation must wake the reader"),
Err(Error::JobCancelled { .. })
));
assert_eq!(
reading.describe().await.unwrap().status,
QueryStatus::Cancelled
);
wait_for(&drops, 1, "cancelling must end the call on the server").await;
reading.cancel().await.unwrap();
let unread = client
.execute(parameterized("SELECT exchange slow", parameter_row()))
.await
.unwrap();
let id = unread.id();
drop(unread);
wait_for(&drops, 2, "dropping an unread handle must end the call").await;
assert_eq!(
client.describe(id).await.unwrap().status,
QueryStatus::Cancelled
);
let dropped_reader = client
.execute(parameterized("SELECT exchange slow", parameter_row()))
.await
.unwrap();
let rows = dropped_reader.reader().await.unwrap();
drop(rows);
wait_for(&drops, 3, "dropping a reader must end the call").await;
}
/// A reader abandoned before the first batch -- its task cancelled -- must
/// leave the rows for the next reader, as a polled query does, rather than
/// take the call down with it.
#[tokio::test]
async fn an_abandoned_reader_leaves_the_rows_for_the_next_one() {
let service = TestSqlService::default();
let drops = service.exchange_drops.clone();
let (client, _shutdown) = start_sql_service(service, ClientConfig::default()).await;
let delayed = Arc::new(
client
.execute(parameterized("SELECT exchange delayed", parameter_row()))
.await
.unwrap(),
);
let abandoned = {
let delayed = delayed.clone();
tokio::spawn(async move { delayed.reader().await.map(|_| ()) })
};
tokio::time::sleep(Duration::from_millis(50)).await;
abandoned.abort();
assert!(abandoned.await.unwrap_err().is_cancelled());
assert_eq!(
delayed.describe().await.unwrap().status,
QueryStatus::Running
);
assert_eq!(drops.load(Ordering::SeqCst), 0, "the call must survive");
assert_eq!(
collect_result(&delayed).await.unwrap(),
vec![parameter_row()]
);
assert_eq!(
delayed.describe().await.unwrap().status,
QueryStatus::Finished
);
}
/// A reader that gives up before the first batch has already taken the rows,
/// so the query must say it failed rather than report `Running` for a call
/// that is gone.
#[tokio::test]
async fn a_reader_that_times_out_reports_the_failure() {
let mut client_config = ClientConfig::default();
client_config.timeout_config.timeout = Some(Duration::from_millis(200));
let (client, _shutdown) = start_sql_service(TestSqlService::default(), client_config).await;
let stalled = client
.execute(parameterized("SELECT exchange stall", parameter_row()))
.await
.unwrap();
assert_overall_timeout(stalled.reader().await, "result");
let Err(retried) = stalled.reader().await else {
panic!("the rows are gone, so a second reader cannot succeed");
};
for error in [
stalled.describe().await.unwrap_err(),
client.describe(stalled.id()).await.unwrap_err(),
retried,
] {
assert!(
matches!(&error, Error::Runtime { message } if message == "SQL query result timed out"),
"{error}"
);
}
}
@@ -430,7 +780,10 @@ async fn catalog_connections_use_explicit_sql_endpoint_and_database_scope() {
let database =
RemoteDatabase::for_catalog("https://catalog.example", Some(name), &options).unwrap();
let query = database
.execute_query_async("SELECT 42", &[])
.execute_query_async(ExecuteQueryRequest {
default_namespace_path: vec![],
..ExecuteQueryRequest::new("SELECT 42")
})
.await
.unwrap();
assert_eq!(
@@ -813,7 +1166,11 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
.submit("SELECT slow", &["public".to_string()])
.await
.unwrap();
let tracked_dropped_reader = client.queries.get(dropped_reader.id()).unwrap();
let Some(RegisteredQuery::Polled(tracked_dropped_reader)) =
client.queries.get(dropped_reader.id())
else {
panic!("a submitted statement is registered as a polled query");
};
let continuation_count_before = first_continuation_count.load(Ordering::SeqCst);
let dropped_result_stream = dropped_reader.reader().await.unwrap();
tokio::time::timeout(Duration::from_millis(100), async {
@@ -1107,8 +1464,11 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
)
.unwrap(),
);
registry.insert(id, query.clone());
assert!(Arc::ptr_eq(&registry.get(id).unwrap(), &query));
registry.insert(id, RegisteredQuery::Polled(query.clone()));
let Some(RegisteredQuery::Polled(found)) = registry.get(id) else {
panic!("the registry returns the query it was given");
};
assert!(Arc::ptr_eq(&found, &query));
}
let expired_id = Uuid::now_v7();
@@ -1125,7 +1485,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
)
.unwrap(),
);
registry.insert(expired_id, expired_query);
registry.insert(expired_id, RegisteredQuery::Polled(expired_query));
assert!(registry.get(expired_id).is_none());
let stale_id = Uuid::now_v7();
@@ -1142,7 +1502,7 @@ async fn submits_polls_fetches_cancels_and_reuses_client() {
.unwrap(),
);
*stale_query.last_accessed.lock().unwrap() = Instant::now() - ABANDONED_QUERY_RETENTION;
registry.insert(stale_id, stale_query.clone());
registry.insert(stale_id, RegisteredQuery::Polled(stale_query.clone()));
drop(stale_query);
assert!(registry.get(stale_id).is_none());
+12 -1
View File
@@ -17,6 +17,10 @@ pub enum QueryStatus {
/// The server is still executing the query.
Running,
/// The server has made the complete result available.
///
/// A query submitted with parameters streams its rows on the call that
/// runs it, so it reports `Finished` only once its reader has received
/// the last batch. Read it rather than waiting for this status.
Finished,
/// The server accepted cancellation but has not confirmed it yet.
Cancelling,
@@ -60,7 +64,9 @@ pub(crate) trait QueryHandle: Send + Sync {
/// A handle to a submitted SQL query.
///
/// The handle can be inspected, opened as an Arrow reader, or cancelled.
/// Dropping it does not cancel the server-side query.
/// Dropping it does not cancel the server-side query, unless the query was
/// submitted with parameters: those run on the call that returns their rows,
/// so dropping the handle before reading, or dropping the reader, stops them.
/// Identifier lookup is scoped to the connection that submitted the query and
/// is not a durable resume mechanism.
pub struct Query {
@@ -88,6 +94,11 @@ impl Query {
}
/// Get a point-in-time description of the query.
///
/// For a query submitted with parameters the status follows its result
/// stream: it stays [`QueryStatus::Running`] until the reader has received
/// every row, so open [`Self::reader`] directly instead of polling for
/// [`QueryStatus::Finished`] first.
pub async fn describe(&self) -> Result<QueryDescription> {
self.handle.describe().await
}