mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user