mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-15 07:06:52 +00:00
fix: preserve FunctionVersion execution controls (#4856)
Function catalog responses include `batch_size`, `row_timeout_seconds`, and `result_stability`, but the Rust and Python `FunctionVersion` models silently discarded them. Preserve these optional execution controls through decoding, typed access, and canonical serialization; versions without the fields retain their existing representation. The regression exercises raw SQL transport responses and public Python sync/async get/list paths. Live Sophon catalog acceptance remains pending under ENT-2073 and the related FunctionVersion round-trip requirement in ENT-2176.
This commit is contained in:
@@ -482,6 +482,10 @@ class FunctionVersion(_RemoteValue):
|
||||
|
||||
``gpu_class`` is the operator-defined GPU pool fixed into this Function
|
||||
version, or ``None`` when registration used the deployment default.
|
||||
|
||||
``batch_size``, ``row_timeout_seconds``, and ``result_stability`` retain
|
||||
the execution controls reported by the catalog. They are ``None`` for
|
||||
versions registered before the corresponding declaration.
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -497,6 +501,19 @@ class FunctionVersion(_RemoteValue):
|
||||
metadata: Mapping[str, str]
|
||||
disabled: bool
|
||||
gpu_class: Optional[str] = None
|
||||
batch_size: Optional[int] = None
|
||||
row_timeout_seconds: Optional[float] = None
|
||||
result_stability: Optional[str] = None
|
||||
|
||||
@field_validator("batch_size", mode="before")
|
||||
@classmethod
|
||||
def _validate_batch_size(cls, value):
|
||||
return _validate_batch_size(value)
|
||||
|
||||
@field_validator("row_timeout_seconds", mode="before")
|
||||
@classmethod
|
||||
def _validate_row_timeout(cls, value):
|
||||
return _validate_row_timeout(value)
|
||||
|
||||
def __call__(self, **arguments: Any) -> FunctionApplication:
|
||||
"""Bind this exact version to named table columns.
|
||||
|
||||
@@ -180,6 +180,22 @@ def test_function_version_exposes_registered_gpu_class():
|
||||
assert json.loads(version.to_canonical_json())["gpu_class"] == "a10g"
|
||||
|
||||
|
||||
def test_function_version_preserves_execution_controls():
|
||||
value = job_result("remote_function_job.json")
|
||||
value.update(
|
||||
batch_size=12,
|
||||
row_timeout_seconds=8.0,
|
||||
result_stability="input_and_context",
|
||||
)
|
||||
version = FunctionVersion.from_json(json.dumps(value))
|
||||
encoded = json.loads(version.to_canonical_json())
|
||||
|
||||
assert encoded == value
|
||||
assert type(encoded["batch_size"]) is int
|
||||
assert type(encoded["row_timeout_seconds"]) is float
|
||||
assert type(encoded["result_stability"]) is str
|
||||
|
||||
|
||||
def test_function_version_binds_named_columns_as_one_immutable_application():
|
||||
version = FunctionVersion.from_json(
|
||||
json.dumps(job_result("remote_function_job.json"))
|
||||
|
||||
@@ -25,7 +25,9 @@ import pytest
|
||||
|
||||
import lancedb
|
||||
from lancedb import BlobReader
|
||||
from lancedb.db import AsyncConnection
|
||||
from lancedb.functions import (
|
||||
FunctionVersion,
|
||||
HuggingFaceModel,
|
||||
PythonRuntimeSpec,
|
||||
SecretBinding,
|
||||
@@ -37,6 +39,7 @@ from lancedb.functions import (
|
||||
udf,
|
||||
)
|
||||
from lancedb.secrets import EnvVarSecret
|
||||
from lancedb.remote.db import RemoteDBConnection
|
||||
from utils import start_server_thread
|
||||
|
||||
THRESHOLD = 20
|
||||
@@ -1865,6 +1868,78 @@ def test_local_function_catalog_operations_are_not_supported(tmp_path):
|
||||
db.drop_function("normalize_score", version=FUNCTION_VERSION)
|
||||
|
||||
|
||||
class _RawFunctionCatalog:
|
||||
def __init__(self, definition, *, encode=False):
|
||||
self.definition = json.dumps(definition) if encode else definition
|
||||
|
||||
async def get_function(self, _name, version, namespace_path):
|
||||
return self.definition
|
||||
|
||||
def list_functions(self, *, namespace_path=None, page_token=None, page_limit=None):
|
||||
return self
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self.definition is None:
|
||||
raise StopAsyncIteration
|
||||
definition, self.definition = self.definition, None
|
||||
return definition
|
||||
|
||||
def num_page_results(self):
|
||||
return int(self.definition is not None)
|
||||
|
||||
def page_token(self):
|
||||
return None
|
||||
|
||||
|
||||
def _assert_execution_control_encoding(version):
|
||||
encoded = json.loads(version.to_canonical_json())
|
||||
assert type(encoded["batch_size"]) is int
|
||||
assert type(encoded["row_timeout_seconds"]) is float
|
||||
assert type(encoded["result_stability"]) is str
|
||||
return encoded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_catalog_path_preserves_execution_controls():
|
||||
definition = json.loads(
|
||||
(FIXTURES / "remote_function_version.canonical.json").read_text()
|
||||
)
|
||||
definition.update(
|
||||
batch_size=12,
|
||||
row_timeout_seconds=8.0,
|
||||
result_stability="input_and_context",
|
||||
)
|
||||
connection = AsyncConnection(_RawFunctionCatalog(definition, encode=True))
|
||||
|
||||
exact = await connection.get_function("embed", version="1")
|
||||
listed = [item async for item in connection.list_functions(page_limit=32)]
|
||||
assert len(listed) == 1
|
||||
for version in [exact, *listed]:
|
||||
assert _assert_execution_control_encoding(version) == definition
|
||||
|
||||
|
||||
def test_sync_catalog_path_preserves_execution_controls():
|
||||
definition = json.loads(
|
||||
(FIXTURES / "remote_function_version.canonical.json").read_text()
|
||||
)
|
||||
definition.update(
|
||||
batch_size=12,
|
||||
row_timeout_seconds=8.0,
|
||||
result_stability="input_and_context",
|
||||
)
|
||||
connection = object.__new__(RemoteDBConnection)
|
||||
connection._conn = _RawFunctionCatalog(FunctionVersion(**definition))
|
||||
|
||||
exact = connection.get_function("embed", version="1")
|
||||
listed = list(connection.list_functions(page_limit=32))
|
||||
assert len(listed) == 1
|
||||
for version in [exact, *listed]:
|
||||
assert _assert_execution_control_encoding(version) == definition
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _mock_remote_function_catalog():
|
||||
state = {"requests": [], "version": None}
|
||||
|
||||
@@ -541,6 +541,31 @@ fn deserialize_object_version<'de, D: Deserializer<'de>>(
|
||||
}
|
||||
}
|
||||
|
||||
/// A positive, finite per-call deadline reported by the catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
struct RowTimeoutSeconds(f64);
|
||||
|
||||
impl Eq for RowTimeoutSeconds {}
|
||||
|
||||
impl RowTimeoutSeconds {
|
||||
fn get(self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RowTimeoutSeconds {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
let seconds = f64::deserialize(deserializer)?;
|
||||
if !seconds.is_finite() || seconds <= 0.0 {
|
||||
return Err(de::Error::custom(
|
||||
"row_timeout_seconds must be positive and finite",
|
||||
));
|
||||
}
|
||||
Ok(Self(seconds))
|
||||
}
|
||||
}
|
||||
|
||||
/// One immutable Function object revision and its executable artifact.
|
||||
///
|
||||
/// The Function is named by its parts, `name` and `namespace_path`, the way a
|
||||
@@ -570,6 +595,19 @@ pub struct FunctionVersion {
|
||||
/// Operator-defined GPU class declared at registration.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
gpu_class: Option<String>,
|
||||
/// Default maximum rows in one callable batch.
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "deserialize_batch_size"
|
||||
)]
|
||||
batch_size: Option<u32>,
|
||||
/// Per-call deadline in seconds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
row_timeout_seconds: Option<RowTimeoutSeconds>,
|
||||
/// Result-stability value declared at registration.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
result_stability: Option<String>,
|
||||
}
|
||||
|
||||
impl FunctionVersion {
|
||||
@@ -629,6 +667,21 @@ impl FunctionVersion {
|
||||
self.gpu_class.as_deref()
|
||||
}
|
||||
|
||||
/// Default maximum rows in one callable batch, when declared.
|
||||
pub fn batch_size(&self) -> Option<u32> {
|
||||
self.batch_size
|
||||
}
|
||||
|
||||
/// Per-call deadline in seconds, when declared.
|
||||
pub fn row_timeout_seconds(&self) -> Option<f64> {
|
||||
self.row_timeout_seconds.map(RowTimeoutSeconds::get)
|
||||
}
|
||||
|
||||
/// Result-stability value declared at registration, when present.
|
||||
pub fn result_stability(&self) -> Option<&str> {
|
||||
self.result_stability.as_deref()
|
||||
}
|
||||
|
||||
pub fn signature(&self) -> &FunctionSignature {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
@@ -2775,6 +2775,9 @@ async fn function_creation_and_description_use_sql_query_handles() {
|
||||
"secret_ref": {"name": "hf-prod"},
|
||||
}]);
|
||||
let mut version_json: serde_json::Value = serde_json::from_str(VERSION).unwrap();
|
||||
version_json["batch_size"] = serde_json::json!(12);
|
||||
version_json["row_timeout_seconds"] = serde_json::json!(8.0);
|
||||
version_json["result_stability"] = serde_json::json!("input_and_context");
|
||||
if with_models {
|
||||
let models = serde_json::json!([{
|
||||
"repo": "sentence-transformers/all-MiniLM-L6-v2",
|
||||
@@ -2815,6 +2818,9 @@ async fn function_creation_and_description_use_sql_query_handles() {
|
||||
let created = job.wait().await.unwrap();
|
||||
assert_eq!(created.name(), "embed");
|
||||
assert_eq!(created.version(), "1");
|
||||
assert_eq!(created.batch_size(), Some(12));
|
||||
assert_eq!(created.row_timeout_seconds(), Some(8.0));
|
||||
assert_eq!(created.result_stability(), Some("input_and_context"));
|
||||
// Assert the actual SQL body, after the same native JSON decode used
|
||||
// by the Python binding, rather than only the Python declaration.
|
||||
{
|
||||
@@ -2831,6 +2837,9 @@ async fn function_creation_and_description_use_sql_query_handles() {
|
||||
let created_json: serde_json::Value =
|
||||
serde_json::from_str(&created.to_canonical_json().unwrap()).unwrap();
|
||||
assert_eq!(created_json["image"].get("models"), expected_models);
|
||||
assert_eq!(created_json["batch_size"], 12);
|
||||
assert_eq!(created_json["row_timeout_seconds"], 8.0);
|
||||
assert_eq!(created_json["result_stability"], "input_and_context");
|
||||
assert_eq!(
|
||||
client.describe(id).await.unwrap().status,
|
||||
QueryStatus::Finished
|
||||
@@ -2840,17 +2849,32 @@ async fn function_creation_and_description_use_sql_query_handles() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(described.name(), "embed");
|
||||
assert_eq!(described.batch_size(), Some(12));
|
||||
assert_eq!(described.row_timeout_seconds(), Some(8.0));
|
||||
assert_eq!(described.result_stability(), Some("input_and_context"));
|
||||
let described_json: serde_json::Value =
|
||||
serde_json::from_str(&described.to_canonical_json().unwrap()).unwrap();
|
||||
assert_eq!(described_json["image"].get("models"), expected_models);
|
||||
assert_eq!(described_json["batch_size"], 12);
|
||||
assert_eq!(described_json["row_timeout_seconds"], 8.0);
|
||||
assert_eq!(described_json["result_stability"], "input_and_context");
|
||||
let listed = db
|
||||
.list_functions(&["team".into()], Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.items.len(), 1);
|
||||
assert_eq!(listed.items[0].batch_size(), Some(12));
|
||||
assert_eq!(listed.items[0].row_timeout_seconds(), Some(8.0));
|
||||
assert_eq!(
|
||||
listed.items[0].result_stability(),
|
||||
Some("input_and_context")
|
||||
);
|
||||
let listed_json: serde_json::Value =
|
||||
serde_json::from_str(&listed.items[0].to_canonical_json().unwrap()).unwrap();
|
||||
assert_eq!(listed_json["image"].get("models"), expected_models);
|
||||
assert_eq!(listed_json["batch_size"], 12);
|
||||
assert_eq!(listed_json["row_timeout_seconds"], 8.0);
|
||||
assert_eq!(listed_json["result_stability"], "input_and_context");
|
||||
let seen = statements.lock().unwrap();
|
||||
assert_eq!(seen[1], "DESCRIBE FUNCTION \"embed\" WITH (version = '1')");
|
||||
shutdown.send(()).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user