diff --git a/docs/src/python/python.md b/docs/src/python/python.md index 0e7164c1a..058f2529a 100644 --- a/docs/src/python/python.md +++ b/docs/src/python/python.md @@ -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 diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 956806cde..ffdd68e5f 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -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 diff --git a/python/python/lancedb/db.py b/python/python/lancedb/db.py index 59fa5b4c6..b4e5c6182 100644 --- a/python/python/lancedb/db.py +++ b/python/python/lancedb/db.py @@ -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]. diff --git a/python/python/lancedb/remote/db.py b/python/python/lancedb/remote/db.py index a06af5d62..911766674 100644 --- a/python/python/lancedb/remote/db.py +++ b/python/python/lancedb/remote/db.py @@ -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 diff --git a/python/python/lancedb/server_info.py b/python/python/lancedb/server_info.py new file mode 100644 index 000000000..d53c6c380 --- /dev/null +++ b/python/python/lancedb/server_info.py @@ -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] diff --git a/python/python/tests/test_remote_db.py b/python/python/tests/test_remote_db.py index 4f707f041..ca2516fda 100644 --- a/python/python/tests/test_remote_db.py +++ b/python/python/tests/test_remote_db.py @@ -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): diff --git a/python/src/connection.rs b/python/src/connection.rs index 1a99c1372..2bf01a6f7 100644 --- a/python/src/connection.rs +++ b/python/src/connection.rs @@ -1285,6 +1285,14 @@ impl Connection { )) } + pub fn server_info(self_: PyRef<'_, Self>) -> PyResult> { + 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> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { diff --git a/rust/lancedb/src/connection.rs b/rust/lancedb/src/connection.rs index fa35dc459..cc398e32b 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -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 { + 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`] diff --git a/rust/lancedb/src/database.rs b/rust/lancedb/src/database.rs index 7b4859faa..23ed9fc3d 100644 --- a/rust/lancedb/src/database.rs +++ b/rust/lancedb/src/database.rs @@ -479,6 +479,13 @@ pub trait Database: async fn list_gpu_classes(&self) -> Result> { gpu_class_catalog_not_supported() } + + /// Read the responding remote server's build identity. + async fn server_info(&self) -> Result { + 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. diff --git a/rust/lancedb/src/error.rs b/rust/lancedb/src/error.rs index 89ad7a229..6aed94d71 100644 --- a/rust/lancedb/src/error.rs +++ b/rust/lancedb/src/error.rs @@ -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, /// Machine-readable input error code, when available. pub error_code: Option, /// 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, @@ -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::(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::(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::() + .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 }, - #[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::().and_then(Self::status_code) } Self::JobFailed { failure, .. } => { @@ -623,7 +650,7 @@ impl Error { Self::Retry { source, .. } => source .downcast_ref::() .and_then(Self::remote_error_metadata), - Self::TableNotFound { source, .. } => source + Self::TableNotFound { source, .. } | Self::JobNotFound { source, .. } => source .downcast_ref::() .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 { + /// Copy supported errors without flattening sources retained by a shared plan + /// or schema cache. + pub(crate) fn clone_shared(&self) -> Option { + #[cfg(feature = "remote")] + match self { + Self::Http { + source, + request_id, + status_code, + } => { + return source + .downcast_ref::() + .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::() + .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, diff --git a/rust/lancedb/src/lib.rs b/rust/lancedb/src/lib.rs index 0f02d90cf..9f800d593 100644 --- a/rust/lancedb/src/lib.rs +++ b/rust/lancedb/src/lib.rs @@ -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; diff --git a/rust/lancedb/src/remote/db.rs b/rust/lancedb/src/remote/db.rs index 578e3bbab..92523ec34 100644 --- a/rust/lancedb/src/remote/db.rs +++ b/rust/lancedb/src/remote/db.rs @@ -1242,16 +1242,39 @@ impl Database for RemoteDatabase { 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 { + 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> { #[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:?}" ); } diff --git a/rust/lancedb/src/remote/sql.rs b/rust/lancedb/src/remote/sql.rs index 949b078e3..9c34f8f90 100644 --- a/rust/lancedb/src/remote/sql.rs +++ b/rust/lancedb/src/remote/sql.rs @@ -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), diff --git a/rust/lancedb/src/remote/sql_test.rs b/rust/lancedb/src/remote/sql_test.rs index d94defac4..eb994f7dd 100644 --- a/rust/lancedb/src/remote/sql_test.rs +++ b/rust/lancedb/src/remote/sql_test.rs @@ -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, diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index fdb547afa..dcceb4260 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -1939,7 +1939,7 @@ fn base_media_type(content_type: &str) -> &str { fn unwrap_shared_error(arc: Arc) -> 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::Runtime { message: arc.to_string(), }, - }, + }), } } @@ -1971,17 +1971,8 @@ async fn fetch_schema( 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::::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| { diff --git a/rust/lancedb/src/server_info.rs b/rust/lancedb/src/server_info.rs new file mode 100644 index 000000000..48daf508b --- /dev/null +++ b/rust/lancedb/src/server_info.rs @@ -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, +} + +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, + /// 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, + /// Default CPU/job worker image; existing jobs can retain earlier pins. + pub configured_job_worker_image: Option, + /// CLI image configured for newly launched SQL jobs. + pub configured_sql_job_image: Option, + /// Configured GPU worker image, not an observation of a running worker. + pub configured_gpu_worker_image: Option, +}