feat(remote): discover server builds and retain error provenance (#4999)

Remote callers can use `db.server_info()` in Rust and Python, including
synchronous and asynchronous Python connections, to identify the
responding server without submitting a query or Job. Typed results
describe the query-node build, component versions, Function protocol and
configured images. Older servers without the endpoint return an explicit
unsupported error.

REST and Flight errors retain the reported build in diagnostic text and
Rust `RemoteErrorMetadata.server`. Typed table/job-not-found errors
preserve their HTTP source and classification, including the
schema-cache path; masked REST errors can obtain provenance from the
response header. Unknown build information stays absent.

The matching native Python SDK was exercised against a real localhost
server (`8829b074` client / `43920fa` server): sync/async discovery,
both SQL version forms and actual REST/Flight error provenance passed.
The product files match this branch; private validation drivers are
excluded. Focused coverage includes typed 404 errors, masked errors, and
the public schema-cache regression. Deployment and published-package
acceptance remain separate.

Server counterpart: https://github.com/lancedb/sophon/pull/10234.

Related: ENT-3565.
This commit is contained in:
Xuanwo
2026-11-13 06:22:35 +08:00
committed by GitHub
parent 6d24446a30
commit cababaecf0
16 changed files with 553 additions and 32 deletions
+12
View File
@@ -37,6 +37,18 @@ embedding_functions`. Prefer keyword arguments for optional parameters.
::: lancedb.db.DBConnection
Use `db.server_info()` (or `await db.server_info()` on an asynchronous connection)
to include the responding server's release, Git revision, build date and Function
runtime information in a bug report. It returns a JSON-compatible dictionary and
does not submit a query or job. Configured images are distinct from the version
of an already running job. During a rolling upgrade, successive requests can
reach different query-node builds. Embedded connections and older servers without
the endpoint raise `NotImplementedError`.
::: lancedb.server_info.ServerBuildInfo
::: lancedb.server_info.ServerInfo
Resource listings return iterators. Use `list(db.list_tables())` to collect all
table names. `db.list_tables().tables` is a list of the first page; `limit`
controls its page size and `page_token` resumes at a saved token. The same
+1
View File
@@ -185,6 +185,7 @@ class Connection(object):
limit: Optional[int],
) -> list[str]: ... # Deprecated: Use list_tables instead
async def open_job(self, job_id: str) -> Job: ...
async def server_info(self) -> str: ...
async def list_gpu_classes(self) -> list[GpuClass]: ...
async def create_function_async(
self, request_json: str, namespace_path: Optional[List[str]] = None
+23
View File
@@ -58,6 +58,7 @@ from lance_namespace import (
from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
from . import __version__
from .server_info import ServerInfo
from ._lancedb import _append_doc_url, connect as lancedb_connect # type: ignore
from .functions import FunctionVersion, FunctionDefinition
from .graph import (
@@ -1476,6 +1477,22 @@ class DBConnection(EnforceOverrides):
)
)
def server_info(self) -> ServerInfo:
"""Return the responding server's build and Function runtime information.
This call submits no query, Function, or job. The response is a
JSON-compatible dictionary, convenient to include in a bug report::
info = db.server_info()
print(info["query_node"]) # release, git_sha, build_date
Requires System Usage permission. Embedded connections and older
servers without this endpoint raise ``NotImplementedError``.
Different calls can reach different builds during a rolling upgrade.
Configured images do not identify the version of a running job.
"""
raise NotImplementedError("Server build discovery requires a remote connection")
def list_gpu_classes(self) -> List[GpuClass]:
"""List the operator-configured Function GPU classes.
@@ -3653,6 +3670,12 @@ class AsyncConnection(object):
"""
return _opened_job(await self._inner.open_job(job_id))
async def server_info(self) -> ServerInfo:
"""Read the responding server's build information. See
[DBConnection.server_info][lancedb.db.DBConnection.server_info].
"""
return json.loads(await self._inner.server_info())
async def list_gpu_classes(self) -> List[GpuClass]:
"""List configured Function GPU classes. See
[DBConnection.list_gpu_classes][lancedb.db.DBConnection.list_gpu_classes].
+9
View File
@@ -4,6 +4,8 @@
from __future__ import annotations
from lancedb.server_info import ServerInfo
from dataclasses import replace
from datetime import timedelta
import json
@@ -966,6 +968,13 @@ class RemoteDBConnection(DBConnection):
"""
return Job(LOOP.run(self._conn.open_job(job_id)))
@override
def server_info(self) -> ServerInfo:
"""Read the responding server's build information. See
[DBConnection.server_info][lancedb.db.DBConnection.server_info].
"""
return LOOP.run(self._conn.server_info())
@override
def list_gpu_classes(self) -> List[GpuClass]:
"""List configured Function GPU classes. See
+33
View File
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Remote server build information returned as JSON-compatible dictionaries."""
from typing import Optional, TypedDict
class ServerBuildInfo(TypedDict):
"""Immutable identity compiled into a server executable."""
release: str
git_sha: str
build_date: Optional[str]
class ServerInfo(TypedDict):
"""The responding query node and its configured Function runtime.
``query_node`` can be ``None`` when the executable has no build metadata.
``build_date`` is a UTC RFC 3339 timestamp when known. During a rolling
upgrade, separate requests can reach different query-node builds.
Configured images do not establish the version of a running job.
"""
query_node: Optional[ServerBuildInfo]
lance_version: str
lancedb_version: str
function_worker_protocol_version: int
configured_query_node_image: Optional[str]
configured_job_worker_image: Optional[str]
configured_sql_job_image: Optional[str]
configured_gpu_worker_image: Optional[str]
+87
View File
@@ -6539,6 +6539,93 @@ def test_remote_job_handle_reports_its_own_detail():
}
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.parametrize("known_build", [False, True])
@pytest.mark.asyncio
async def test_remote_server_info(is_async, known_build):
requests = []
expected = {
"query_node": {
"release": "1.2.3",
"git_sha": "0123456789ab",
"build_date": "2024-01-01T00:00:00Z",
}
if known_build
else None,
"lance_version": "5.0.0",
"lancedb_version": "0.42.0",
"function_worker_protocol_version": 25,
"configured_query_node_image": "example.invalid/query:configured",
"configured_job_worker_image": "example.invalid/cpu:configured",
"configured_sql_job_image": "example.invalid/cli:configured",
"configured_gpu_worker_image": "example.invalid/worker:configured",
}
def handler(request):
requests.append((request.command, request.path))
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(json.dumps(expected).encode())
if is_async:
async with mock_lancedb_connection_async(handler) as db:
actual = await db.server_info()
else:
with mock_lancedb_connection(handler) as db:
actual = db.server_info()
assert actual == expected
assert json.loads(json.dumps(actual)) == expected
assert requests == [("GET", "/v1/server/info")]
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.parametrize("status", [404, 403])
@pytest.mark.asyncio
async def test_remote_server_info_errors(is_async, status):
requests = []
def handler(request):
requests.append((request.command, request.path))
request.send_response(status)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
{
"error": "discovery unavailable",
"server": {
"release": "1.2.3",
"git_sha": "0123456789ab",
"build_date": "2024-01-01T00:00:00Z",
},
}
).encode()
)
expected_error = NotImplementedError if status == 404 else HttpError
with pytest.raises(expected_error) as caught:
if is_async:
async with mock_lancedb_connection_async(handler) as db:
await db.server_info()
else:
with mock_lancedb_connection(handler) as db:
db.server_info()
if status == 404:
assert "/v1/server/info" in str(caught.value)
else:
# Permission responses do not carry the input-error classifier header.
# The native HTTP error and Python conversion must retain provenance.
assert "discovery unavailable" in str(caught.value)
assert "1.2.3" in str(caught.value)
assert "0123456789ab" in str(caught.value)
assert "2024-01-01T00:00:00Z" in str(caught.value)
assert caught.value.status_code == 403
assert requests == [("GET", "/v1/server/info")]
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_remote_list_gpu_classes(is_async):
+8
View File
@@ -1285,6 +1285,14 @@ impl Connection {
))
}
pub fn server_info(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move {
let info = inner.server_info().await.infer_error()?;
serde_json::to_string(&info).map_err(|error| PyValueError::new_err(error.to_string()))
})
}
pub fn list_gpu_classes(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move {
+18
View File
@@ -1303,6 +1303,24 @@ impl Connection {
self.database().list_gpu_classes().await
}
/// Read the responding server's build and Function runtime information.
///
/// This request does not submit a query, Function, or job. It requires
/// System Usage permission. Embedded databases and older servers without
/// `GET /v1/server/info` return [`Error::NotSupported`]. During a rolling
/// upgrade, separate requests can report different query-node builds.
///
/// ```no_run
/// # async fn example(db: &lancedb::Connection) -> lancedb::Result<()> {
/// let info = db.server_info().await?;
/// println!("{:?}", info.query_node);
/// # Ok(())
/// # }
/// ```
pub async fn server_info(&self) -> Result<crate::server_info::ServerInfo> {
self.database().server_info().await
}
/// List server-side jobs across the database's tables.
///
/// Results are fetched lazily, one page at a time. See [`Listing`]
+7
View File
@@ -479,6 +479,13 @@ pub trait Database:
async fn list_gpu_classes(&self) -> Result<Vec<GpuClass>> {
gpu_class_catalog_not_supported()
}
/// Read the responding remote server's build identity.
async fn server_info(&self) -> Result<crate::server_info::ServerInfo> {
Err(crate::Error::NotSupported {
message: "Server build discovery requires a remote LanceDB connection".into(),
})
}
/// Create a materialized view through a remote catalog and return its
/// initial-population job. Local connections use the native declaration
/// path directly.
+65 -12
View File
@@ -64,6 +64,8 @@ impl RemoteResourceIdentity {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RemoteErrorMetadata {
/// Build of the executable that produced the error, when reported.
pub server: Option<crate::server_info::ServerBuildInfo>,
/// Machine-readable input error code, when available.
pub error_code: Option<String>,
/// SQL job that failed, when the request ran in an isolated job.
@@ -84,6 +86,8 @@ impl RemoteErrorMetadata {
.map(str::to_owned)
};
Self {
server: header("x-lancedb-server-build")
.and_then(|value| serde_json::from_str(&value).ok()),
error_code: header("x-lancedb-error-code"),
job_id: header("x-lancedb-sql-job-id"),
job_request_id: header("x-job-request-id"),
@@ -119,7 +123,7 @@ pub(crate) fn parse_flight_resource_identity(value: &str) -> Option<RemoteResour
}
#[cfg(feature = "remote")]
#[derive(Debug)]
#[derive(Debug, Clone)]
pub(crate) struct HttpErrorSource {
message: String,
details: Option<HttpErrorDetails>,
@@ -131,17 +135,28 @@ impl HttpErrorSource {
pub(crate) fn new(
message: String,
response_body: Option<&str>,
metadata: RemoteErrorMetadata,
mut metadata: RemoteErrorMetadata,
) -> Self {
let details = response_body.and_then(parse_http_error_details);
let body =
response_body.and_then(|body| serde_json::from_str::<serde_json::Value>(body).ok());
if metadata.server.is_none() {
metadata.server = body
.as_ref()
.and_then(|body| body.get("server").cloned())
.and_then(|server| serde_json::from_value(server).ok());
}
let message = if metadata.error_code.is_some() {
response_body
.and_then(|body| serde_json::from_str::<serde_json::Value>(body).ok())
body.as_ref()
.and_then(|body| body.get("error")?.as_str().map(str::to_owned))
.unwrap_or(message)
} else {
message
};
let message = match &metadata.server {
Some(server) => format!("{message} [server: {server}]"),
None => message,
};
Self {
message,
details,
@@ -317,6 +332,18 @@ impl Display for JobFailure {
}
}
fn server_build_suffix(_source: &(dyn std::error::Error + Send + Sync + 'static)) -> String {
#[cfg(feature = "remote")]
if let Some(server) = _source
.downcast_ref::<Error>()
.and_then(Error::remote_error_metadata)
.and_then(|metadata| metadata.server.as_ref())
{
return format!(" [server: {server}]");
}
String::new()
}
#[derive(Debug, Snafu)]
#[snafu(visibility(pub(crate)))]
pub enum Error {
@@ -324,7 +351,7 @@ pub enum Error {
InvalidTableName { name: String, reason: String },
#[snafu(display("Invalid input, {message}"))]
InvalidInput { message: String },
#[snafu(display("Table '{name}' was not found"))]
#[snafu(display("Table '{name}' was not found{}", server_build_suffix(source.as_ref())))]
TableNotFound { name: String, source: BoxError },
#[snafu(display(
"Table '{name}' exists but could not be loaded (it may be corrupt or incomplete): {source}"
@@ -385,8 +412,8 @@ pub enum Error {
},
#[snafu(display("Job{} was cancelled", job_id.as_ref().map(|id| format!(" {id}")).unwrap_or_default()))]
JobCancelled { job_id: Option<String> },
#[snafu(display("Job '{job_id}' was not found"))]
JobNotFound { job_id: String },
#[snafu(display("Job '{job_id}' was not found{}", server_build_suffix(source.as_ref())))]
JobNotFound { job_id: String, source: BoxError },
// 3rd party / external errors
#[snafu(display("object_store error: {source}"))]
@@ -576,7 +603,7 @@ impl Error {
match self {
Self::Http { status_code, .. } => *status_code,
Self::Retry { status_code, .. } => *status_code,
Self::TableNotFound { source, .. } => {
Self::TableNotFound { source, .. } | Self::JobNotFound { source, .. } => {
source.downcast_ref::<Self>().and_then(Self::status_code)
}
Self::JobFailed { failure, .. } => {
@@ -623,7 +650,7 @@ impl Error {
Self::Retry { source, .. } => source
.downcast_ref::<Self>()
.and_then(Self::remote_error_metadata),
Self::TableNotFound { source, .. } => source
Self::TableNotFound { source, .. } | Self::JobNotFound { source, .. } => source
.downcast_ref::<Self>()
.and_then(Self::remote_error_metadata),
Self::JobFailed { failure, .. } => failure
@@ -635,9 +662,35 @@ impl Error {
}
}
/// A copy of an error that other partitions of a DataFusion plan still hold.
/// Only an interrupted response can be copied, because it shares its source.
fn clone_shared(&self) -> Option<Self> {
/// Copy supported errors without flattening sources retained by a shared plan
/// or schema cache.
pub(crate) fn clone_shared(&self) -> Option<Self> {
#[cfg(feature = "remote")]
match self {
Self::Http {
source,
request_id,
status_code,
} => {
return source
.downcast_ref::<HttpErrorSource>()
.map(|source| Self::Http {
source: Box::new(source.clone()),
request_id: request_id.clone(),
status_code: *status_code,
});
}
Self::TableNotFound { name, source } => {
return source
.downcast_ref::<Self>()
.and_then(Self::clone_shared)
.map(|source| Self::TableNotFound {
name: name.clone(),
source: Box::new(source),
});
}
_ => {}
}
#[cfg(feature = "remote")]
if let Self::ResponseInterrupted {
request_id,
+1
View File
@@ -216,6 +216,7 @@ pub mod query;
pub mod remote;
pub mod rerankers;
pub mod secrets;
pub mod server_info;
pub mod sql;
pub mod storage_integration;
pub mod table;
+128 -5
View File
@@ -1242,16 +1242,39 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
let handle = super::job::RemoteJob::new(self.client.clone(), job_id.to_string());
match crate::job::JobHandle::describe(&handle).await {
Ok(description) => Ok(Job::opened(Box::new(handle), description)),
Err(Error::Http {
status_code: Some(StatusCode::NOT_FOUND),
..
}) => Err(Error::JobNotFound {
Err(
error @ Error::Http {
status_code: Some(StatusCode::NOT_FOUND),
..
},
) => Err(Error::JobNotFound {
job_id: job_id.to_string(),
source: Box::new(error),
}),
Err(err) => Err(err),
}
}
async fn server_info(&self) -> Result<crate::server_info::ServerInfo> {
let req = self.client.get("/v1/server/info");
let (request_id, response) = self.client.send(req).await?;
let response = match self.client.check_response(&request_id, response).await {
Ok(response) => response,
Err(Error::Http {
status_code: Some(StatusCode::NOT_FOUND),
..
}) => {
return Err(Error::NotSupported {
message: "Server build discovery requires a newer LanceDB server; \
GET /v1/server/info returned 404 Not Found"
.into(),
});
}
Err(error) => return Err(error),
};
response.json().await.err_to_http(request_id)
}
async fn list_gpu_classes(&self) -> Result<Vec<GpuClass>> {
#[derive(serde::Deserialize)]
struct Response {
@@ -4155,6 +4178,106 @@ mod tests {
}
}
#[tokio::test]
async fn test_server_info_preserves_remote_provenance() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/server/info");
http::Response::builder().status(200).body(serde_json::json!({
"query_node": { "release": "1.2.3", "git_sha": "0123456789ab", "build_date": "2024-01-01T00:00:00Z" },
"lance_version": "5.0.0",
"lancedb_version": "0.42.0",
"function_worker_protocol_version": 25,
"configured_gpu_worker_image": "example.invalid/worker:configured",
"future_component": "ignored"
}).to_string()).unwrap()
});
let info = conn.server_info().await.unwrap();
let build = info.query_node.unwrap();
assert_eq!(build.release, "1.2.3");
assert_eq!(build.git_sha, "0123456789ab");
assert_eq!(build.build_date.as_deref(), Some("2024-01-01T00:00:00Z"));
assert_eq!(info.function_worker_protocol_version, 25);
assert_eq!(
info.configured_gpu_worker_image.as_deref(),
Some("example.invalid/worker:configured")
);
}
#[tokio::test]
async fn test_server_info_error_provenance_survives_classification() {
for target in ["table", "job", "masked"] {
let conn = Connection::new_with_handler(move |_| {
let server = serde_json::json!({
"release": "1.2.3", "git_sha": "0123456789ab",
"build_date": "2024-01-01T00:00:00Z",
});
if target == "masked" {
http::Response::builder()
.status(500)
.header("x-lancedb-server-build", server.to_string())
.body("Internal server error".to_string())
.unwrap()
} else {
http::Response::builder()
.status(404)
.body(serde_json::json!({"error": "missing", "server": server}).to_string())
.unwrap()
}
});
let error = match target {
"table" => conn.open_table("missing").execute().await.unwrap_err(),
"job" => conn.open_job("missing").await.unwrap_err(),
_ => conn.server_info().await.unwrap_err(),
};
match target {
"table" => assert!(matches!(&error, Error::TableNotFound { .. })),
"job" => assert!(matches!(&error, Error::JobNotFound { .. })),
_ => assert_eq!(
error.status_code(),
Some(http::StatusCode::INTERNAL_SERVER_ERROR)
),
}
let server = error
.remote_error_metadata()
.unwrap()
.server
.as_ref()
.unwrap();
assert_eq!(server.git_sha, "0123456789ab");
assert_eq!(server.release, "1.2.3");
let message = error.to_string();
assert!(message.contains("0123456789ab"), "{message}");
assert!(message.contains("2024-01-01T00:00:00Z"), "{message}");
}
}
#[tokio::test]
async fn test_server_info_distinguishes_unsupported_from_denied() {
for status in [404, 403] {
let conn = Connection::new_with_handler(move |_| {
http::Response::builder()
.status(status)
.body("unavailable")
.unwrap()
});
let error = conn.server_info().await.unwrap_err();
if status == 404 {
assert!(
matches!(error, Error::NotSupported { message } if message.contains("/v1/server/info"))
);
} else {
assert!(matches!(
error,
Error::Http {
status_code: Some(http::StatusCode::FORBIDDEN),
..
}
));
}
}
}
#[tokio::test]
async fn test_list_gpu_classes_reports_configured_hardware() {
let conn = Connection::new_with_handler(|request| {
@@ -4368,7 +4491,7 @@ mod tests {
});
let err = conn.open_job("nope").await.unwrap_err();
assert!(
matches!(&err, Error::JobNotFound { job_id } if job_id == "nope"),
matches!(&err, Error::JobNotFound { job_id, .. } if job_id == "nope"),
"{err:?}"
);
}
+11 -2
View File
@@ -1797,6 +1797,11 @@ pub(super) fn sql_status_error(request_id: &str, status: &tonic::Status) -> Erro
.map(str::to_owned)
};
let metadata = crate::error::RemoteErrorMetadata {
server: status
.metadata()
.get_bin("x-lancedb-server-build-bin")
.and_then(|value| value.to_bytes().ok())
.and_then(|value| serde_json::from_slice(&value).ok()),
error_code: header("x-lancedb-error-code"),
job_id: header(SQL_JOB_ID_METADATA_KEY),
job_request_id: header("x-job-request-id"),
@@ -1805,8 +1810,12 @@ pub(super) fn sql_status_error(request_id: &str, status: &tonic::Status) -> Erro
.and_then(crate::error::parse_flight_resource_identity),
};
let sql_job_id = metadata.job_id.clone();
let mut message = status.message().to_owned();
if let Some(server) = &metadata.server {
message.push_str(&format!(" [server: {server}]"));
}
let error = Error::Sql {
message: status.message().into(),
message: message.clone(),
request_id: request_id.into(),
grpc_code: status.code() as i32,
metadata: Box::new(metadata),
@@ -1816,7 +1825,7 @@ pub(super) fn sql_status_error(request_id: &str, status: &tonic::Status) -> Erro
job_id: Some(sql_job_id.clone()),
failure: Box::new(JobFailure {
phase: None,
message: Some(status.message().into()),
message: Some(message),
traceback: None,
retryable: None,
sql_job_id: Some(sql_job_id),
+47
View File
@@ -33,6 +33,53 @@ use crate::remote::table::RemoteTable;
use crate::storage_integration::{CreateStorageIntegrationRequest, SecretReference};
use crate::table::BaseTable;
#[test]
fn server_info_survives_flight_error_and_job_wrapping() {
for with_job in [false, true] {
let mut status = tonic::Status::invalid_argument("invalid query");
status
.metadata_mut()
.insert("x-lancedb-error-code", "invalid_query".parse().unwrap());
let build = serde_json::json!({
"release": "1.2.3", "git_sha": "0123456789ab", "build_date": "2024-01-01T00:00:00Z"
})
.to_string();
status.metadata_mut().insert_bin(
"x-lancedb-server-build-bin",
tonic::metadata::MetadataValue::from_bytes(build.as_bytes()),
);
if with_job {
status
.metadata_mut()
.insert(SQL_JOB_ID_METADATA_KEY, "job-original".parse().unwrap());
}
let error = sql_status_error("request-original", &status);
let text = error.to_string();
assert!(text.contains("invalid query"), "{text}");
assert!(text.contains("1.2.3"), "{text}");
assert!(text.contains("0123456789ab"), "{text}");
assert!(text.contains("2024-01-01T00:00:00Z"), "{text}");
assert_eq!(
error.remote_error_metadata().unwrap().error_code.as_deref(),
Some("invalid_query")
);
match error {
Error::JobFailed { job_id, .. } if with_job => {
assert_eq!(job_id.as_deref(), Some("job-original"))
}
Error::Sql {
grpc_code,
request_id,
..
} if !with_job => {
assert_eq!(grpc_code, tonic::Code::InvalidArgument as i32);
assert_eq!(request_id, "request-original");
}
other => panic!("unexpected error classification: {other}"),
}
}
}
#[derive(Debug, Default)]
struct DelayedHeaderProvider {
delay_next: AtomicBool,
+47 -13
View File
@@ -1939,7 +1939,7 @@ fn base_media_type(content_type: &str) -> &str {
fn unwrap_shared_error(arc: Arc<Error>) -> Error {
match Arc::try_unwrap(arc) {
Ok(err) => err,
Err(arc) => match &*arc {
Err(arc) => arc.clone_shared().unwrap_or_else(|| match &*arc {
Error::TableNotFound { name, source } => Error::TableNotFound {
name: name.clone(),
source: source.to_string().into(),
@@ -1947,7 +1947,7 @@ fn unwrap_shared_error(arc: Arc<Error>) -> Error {
_ => Error::Runtime {
message: arc.to_string(),
},
},
}),
}
}
@@ -1971,17 +1971,8 @@ async fn fetch_schema<S: HttpSend>(
let (request_id, response) = client.send_with_retry(request, None, true).await?;
if response.status() == StatusCode::NOT_FOUND {
let body = response.text().await.ok().unwrap_or_default();
return Err(Error::TableNotFound {
name: table_name.to_string(),
source: Box::new(Error::Http {
source: body.into(),
request_id,
status_code: Some(StatusCode::NOT_FOUND),
}),
});
}
let response =
RemoteTable::<S>::handle_table_not_found(table_name, response, &request_id).await?;
let response = client.check_response(&request_id, response).await?;
freshness_headers.observe_headers(&freshness, response.headers());
@@ -4922,6 +4913,49 @@ mod tests {
assert_eq!(schema, expected);
}
#[tokio::test]
async fn test_server_info_survives_schema_cache_errors() {
let expected = crate::server_info::ServerBuildInfo {
release: "1.2.3".into(),
git_sha: "0123456789ab".into(),
build_date: Some("2024-01-01T00:00:00Z".into()),
};
for header_only in [false, true] {
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/describe/");
let server = serde_json::json!({
"release": "1.2.3", "git_sha": "0123456789ab",
"build_date": "2024-01-01T00:00:00Z",
});
let response = http::Response::builder().status(404);
if header_only {
response
.header("x-lancedb-server-build", server.to_string())
.body("missing".to_string())
.unwrap()
} else {
response
.body(serde_json::json!({"error": "missing", "server": server}).to_string())
.unwrap()
}
});
let original = Arc::new(table.schema().await.unwrap_err());
// Keep another reader's reference alive to exercise the shared result.
let shared = unwrap_shared_error(original.clone());
for error in [original.as_ref(), &shared] {
assert!(matches!(error, Error::TableNotFound { name, .. } if name == "my_table"));
assert_eq!(error.status_code(), Some(StatusCode::NOT_FOUND));
assert_eq!(
error
.remote_error_metadata()
.and_then(|metadata| metadata.server.as_ref()),
Some(&expected),
);
assert!(error.to_string().contains("0123456789ab"));
}
}
}
#[tokio::test]
async fn test_count_rows() {
let table = Table::new_with_handler("my_table", |request| {
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! Remote server provenance for diagnostics without submitting a query or job.
use serde::{Deserialize, Serialize};
use std::fmt;
/// Immutable identity compiled into a server executable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ServerBuildInfo {
/// Release of this executable.
pub release: String,
/// Git revision used to build the executable.
pub git_sha: String,
/// UTC RFC 3339 build timestamp, when known.
pub build_date: Option<String>,
}
impl fmt::Display for ServerBuildInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} (git {}, built {})",
self.release,
self.git_sha,
self.build_date.as_deref().unwrap_or("unknown")
)
}
}
/// Identity of the query node that answered a discovery request.
///
/// Different requests can reach different versions during a rolling upgrade.
/// Configured images do not establish which version a running job uses.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ServerInfo {
/// The responding executable; absent if its identity was not initialized.
pub query_node: Option<ServerBuildInfo>,
/// Lance library linked into the responding server.
pub lance_version: String,
/// LanceDB library linked into the responding server.
pub lancedb_version: String,
/// Function worker protocol supported by this server build.
pub function_worker_protocol_version: u16,
/// Configured query-node image, not an independently observed digest.
pub configured_query_node_image: Option<String>,
/// Default CPU/job worker image; existing jobs can retain earlier pins.
pub configured_job_worker_image: Option<String>,
/// CLI image configured for newly launched SQL jobs.
pub configured_sql_job_image: Option<String>,
/// Configured GPU worker image, not an observation of a running worker.
pub configured_gpu_worker_image: Option<String>,
}