mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
feat: persist automatic Function refresh resource budgets (#4953)
## Depends on merged #4951 Phase 1 merged as `4d22c0ce2b6825c880c818e737b079db494b4053`, so this PR now targets `main`. It reuses the canonical `FunctionWorkerResourceBudget` introduced there rather than copying the type. For Function `add_columns` declarations, Python and Rust now persist `auto_refresh_resource_budget` in computed-column metadata and send it as `auto_refresh_resource_budget` in the remote request. CPU milliseconds and memory bytes are required positive integers. Omitting `gpu_count` is accepted as zero and the canonical outgoing JSON still contains `"gpu_count": 0`. Unknown fields, missing CPU or memory, wrong types, zero CPU or memory, negative values, and overflow are rejected. The same budget is applied to every public output binding of a multi-output Function declaration. Existing metadata is retained, and SQL-expression declarations or local execution reject the budget instead of silently ignoring it. Node is unchanged because its current `addColumns` surface only accepts SQL computed expressions and has no Function declaration path. This change does not implement fleet call concurrency limits or enforce the budget in the runtime. The Enterprise server must support the nested Function worker resource-budget contract before these values can act as execution limits. ## Validation - Python extension build passed. - Remote Function declaration and local unsupported-path rejection each passed. - Canonical metadata round-trip passed, including omitted GPU decoding to zero and explicit `gpu_count: 0` on encode. - Function multi-output remote wire test passed with `--features remote`. - Repository-scoped Python type check passed with 0 errors. - All runtime source blobs match the native-tested source. The post-native deltas are one documentation example line and an existing routing-test stub update for the new optional argument; the affected parameterized test passed 3/3 against the existing extension build. No private test runners are part of this PR.
This commit is contained in:
@@ -615,6 +615,7 @@ class Table:
|
||||
columns: list[tuple[str, str]],
|
||||
auto_refresh_enabled: Optional[bool] = None,
|
||||
auto_refresh_interval_seconds: Optional[int] = None,
|
||||
auto_refresh_resource_budget: Optional[dict[str, int]] = None,
|
||||
) -> AddColumnsResult: ...
|
||||
async def add_function_columns(
|
||||
self,
|
||||
@@ -622,6 +623,7 @@ class Table:
|
||||
output_name: Optional[str],
|
||||
auto_refresh_enabled: Optional[bool] = None,
|
||||
auto_refresh_interval_seconds: Optional[int] = None,
|
||||
auto_refresh_resource_budget: Optional[dict[str, int]] = None,
|
||||
) -> AddColumnsResult: ...
|
||||
async def refresh_column(
|
||||
self,
|
||||
|
||||
@@ -1127,6 +1127,7 @@ class RemoteTable(Table):
|
||||
computed: Dict[str, str] | None = None,
|
||||
auto_refresh_enabled: bool | None = None,
|
||||
auto_refresh_interval_seconds: int | None = None,
|
||||
auto_refresh_resource_budget: Optional[Dict[str, int]] = None,
|
||||
) -> AddColumnsResult:
|
||||
return LOOP.run(
|
||||
self._table.add_columns(
|
||||
@@ -1134,6 +1135,7 @@ class RemoteTable(Table):
|
||||
computed=computed,
|
||||
auto_refresh_enabled=auto_refresh_enabled,
|
||||
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
|
||||
auto_refresh_resource_budget=auto_refresh_resource_budget,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -3085,6 +3085,7 @@ class Table(ABC):
|
||||
computed: Dict[str, str] | None = None,
|
||||
auto_refresh_enabled: bool | None = None,
|
||||
auto_refresh_interval_seconds: int | None = None,
|
||||
auto_refresh_resource_budget: Optional[Dict[str, int]] = None,
|
||||
):
|
||||
"""
|
||||
Add new columns with defined values.
|
||||
@@ -3153,6 +3154,12 @@ class Table(ABC):
|
||||
When omitted, the server determines when to refresh. Applies to every
|
||||
computed output in this call.
|
||||
Scheduling options require computed or Function columns.
|
||||
auto_refresh_resource_budget: dict[str, int], optional
|
||||
Aggregate CPU, memory, and GPU budget for automatic refreshes of a
|
||||
registered Function declaration. ``cpu_millis`` and
|
||||
``memory_bytes`` are required; ``gpu_count`` defaults to zero.
|
||||
This option cannot be used with SQL-expression columns. LanceDB
|
||||
Enterprise only.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -5468,6 +5475,7 @@ class LanceTable(Table):
|
||||
computed: Dict[str, str] | None = None,
|
||||
auto_refresh_enabled: bool | None = None,
|
||||
auto_refresh_interval_seconds: int | None = None,
|
||||
auto_refresh_resource_budget: Optional[Dict[str, int]] = None,
|
||||
) -> AddColumnsResult:
|
||||
return LOOP.run(
|
||||
self._table.add_columns(
|
||||
@@ -5475,6 +5483,7 @@ class LanceTable(Table):
|
||||
computed=computed,
|
||||
auto_refresh_enabled=auto_refresh_enabled,
|
||||
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
|
||||
auto_refresh_resource_budget=auto_refresh_resource_budget,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7758,6 +7767,7 @@ class AsyncTable:
|
||||
computed: dict[str, str] | None = None,
|
||||
auto_refresh_enabled: bool | None = None,
|
||||
auto_refresh_interval_seconds: int | None = None,
|
||||
auto_refresh_resource_budget: Optional[Dict[str, int]] = None,
|
||||
) -> AddColumnsResult:
|
||||
"""
|
||||
Add new columns with defined values.
|
||||
@@ -7824,6 +7834,12 @@ class AsyncTable:
|
||||
not force recomputation. Applies to every declared computed output.
|
||||
These options cannot be used with one-time transforms or ordinary
|
||||
all-null columns.
|
||||
auto_refresh_resource_budget: dict[str, int], optional
|
||||
Aggregate CPU, memory, and GPU budget for automatic refreshes of
|
||||
registered Function outputs. ``cpu_millis`` and ``memory_bytes``
|
||||
are required; ``gpu_count`` defaults to zero. SQL-expression
|
||||
columns do not use Function workers and reject this option.
|
||||
LanceDB Enterprise only.
|
||||
|
||||
Examples
|
||||
--------
|
||||
@@ -7852,6 +7868,7 @@ class AsyncTable:
|
||||
options_supplied = (
|
||||
auto_refresh_enabled is not None
|
||||
or auto_refresh_interval_seconds is not None
|
||||
or auto_refresh_resource_budget is not None
|
||||
)
|
||||
function_application = None
|
||||
function_output_name = None
|
||||
@@ -7880,6 +7897,7 @@ class AsyncTable:
|
||||
function_output_name,
|
||||
auto_refresh_enabled,
|
||||
auto_refresh_interval_seconds,
|
||||
auto_refresh_resource_budget,
|
||||
)
|
||||
|
||||
if isinstance(transforms, pa.Field):
|
||||
@@ -7900,6 +7918,7 @@ class AsyncTable:
|
||||
list(computed.items()),
|
||||
auto_refresh_enabled,
|
||||
auto_refresh_interval_seconds,
|
||||
auto_refresh_resource_budget,
|
||||
)
|
||||
if options_supplied:
|
||||
raise ValueError(
|
||||
|
||||
@@ -459,6 +459,7 @@ class _FunctionDeclarationInner:
|
||||
output_name,
|
||||
auto_refresh_enabled,
|
||||
auto_refresh_interval_seconds,
|
||||
auto_refresh_resource_budget,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
@@ -466,6 +467,7 @@ class _FunctionDeclarationInner:
|
||||
output_name,
|
||||
auto_refresh_enabled,
|
||||
auto_refresh_interval_seconds,
|
||||
auto_refresh_resource_budget,
|
||||
)
|
||||
)
|
||||
return "declared"
|
||||
@@ -491,7 +493,7 @@ async def test_add_columns_routes_struct_as_one_and_multi_output_binding_atomica
|
||||
)
|
||||
assert result == "declared"
|
||||
assert inner.calls[-1][1] == "features"
|
||||
assert inner.calls[-1][2:] == (None, None)
|
||||
assert inner.calls[-1][2:] == (None, None, None)
|
||||
|
||||
bare = application._copy(update={"columns": {}}).rename(
|
||||
columns={"normalized_text": "search_text"}
|
||||
@@ -502,7 +504,7 @@ async def test_add_columns_routes_struct_as_one_and_multi_output_binding_atomica
|
||||
assert result == "declared"
|
||||
assert inner.calls[-1][1] is None
|
||||
assert inner.calls[-1][0]["columns"] == {"normalized_text": "search_text"}
|
||||
assert inner.calls[-1][2:] == (False, 300)
|
||||
assert inner.calls[-1][2:] == (False, 300, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -36,6 +36,7 @@ from lancedb.graph import (
|
||||
PropertyGraph,
|
||||
PropertyGraphDescription,
|
||||
)
|
||||
from lancedb.functions import FunctionApplication
|
||||
from lancedb.query import (
|
||||
AsyncQuery,
|
||||
ColumnOrdering,
|
||||
@@ -5831,6 +5832,92 @@ def test_remote_add_columns_reader_not_supported():
|
||||
assert all("/add_columns/" not in path for path in requests)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_add_function_columns_forwards_auto_refresh_resource_budget():
|
||||
bodies = []
|
||||
application = FunctionApplication.from_json(
|
||||
json.dumps(
|
||||
{
|
||||
"function": {
|
||||
"name": "embed",
|
||||
"version": "1",
|
||||
"object_id": "fixture",
|
||||
"location": "memory:///fixture",
|
||||
"manifest_digest": (
|
||||
"sha256:7e22f815b6648e14f093a3979a8e5a2082"
|
||||
"fa773ebe1ec84b135cae7e84d6f8e6"
|
||||
),
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"parameter": "text",
|
||||
"kind": "column",
|
||||
"value": {"path": "text"},
|
||||
}
|
||||
],
|
||||
"output": {
|
||||
"kind": "scalar",
|
||||
"arrow_type": "float32",
|
||||
"nullable": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def handler(request):
|
||||
if request.path == "/v1/table/test/describe/":
|
||||
response = {
|
||||
"version": 1,
|
||||
"schema": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": {"type": "string"},
|
||||
"nullable": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(json.dumps(response).encode())
|
||||
elif request.path == "/v1/table/test/add_columns/":
|
||||
content_len = int(request.headers.get("Content-Length", 0))
|
||||
bodies.append(json.loads(request.rfile.read(content_len)))
|
||||
request.send_response(200)
|
||||
request.send_header("Content-Type", "application/json")
|
||||
request.end_headers()
|
||||
request.wfile.write(b'{"version": 2}')
|
||||
else:
|
||||
request.send_response(404)
|
||||
request.end_headers()
|
||||
|
||||
budget = {"cpu_millis": 2_000, "memory_bytes": 4 * 1024**3}
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
table = db.open_table("test")
|
||||
table.add_columns(
|
||||
{"embedding": application},
|
||||
auto_refresh_resource_budget=budget,
|
||||
)
|
||||
|
||||
async with mock_lancedb_connection_async(handler) as db:
|
||||
table = await db.open_table("test")
|
||||
await table.add_columns(
|
||||
{"embedding": application},
|
||||
auto_refresh_resource_budget=budget,
|
||||
)
|
||||
|
||||
assert len(bodies) == 2
|
||||
for body in bodies:
|
||||
assert body["auto_refresh_enabled"] is True
|
||||
assert body["auto_refresh_resource_budget"] == {
|
||||
**budget,
|
||||
"gpu_count": 0,
|
||||
}
|
||||
assert body["new_columns"] == [{"name": "embedding", "all_null": True}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_property_graph_handles():
|
||||
calls = []
|
||||
|
||||
@@ -6255,6 +6255,21 @@ def test_auto_refresh_options_require_computed_columns(tmp_path):
|
||||
table.add_columns({"doubled": "x * 2"}, auto_refresh_enabled=True)
|
||||
|
||||
|
||||
def test_auto_refresh_resource_budget_requires_a_function(tmp_path):
|
||||
table = lancedb.connect(tmp_path).create_table("invalid_budget", [{"x": 1}])
|
||||
before = table.version
|
||||
with pytest.raises(ValueError, match="registered Function"):
|
||||
table.add_columns(
|
||||
computed={"doubled": "x * 2"},
|
||||
auto_refresh_resource_budget={
|
||||
"cpu_millis": 1_000,
|
||||
"memory_bytes": 1_024,
|
||||
},
|
||||
)
|
||||
assert table.version == before
|
||||
assert table.schema.names == ["x"]
|
||||
|
||||
|
||||
@pytest.mark.experimental_functions
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_computed_auto_refresh_uses_server_interval(tmp_path):
|
||||
|
||||
+16
-8
@@ -2217,17 +2217,21 @@ impl Table {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (columns, auto_refresh_enabled=None, auto_refresh_interval_seconds=None))]
|
||||
pub fn add_computed_columns(
|
||||
self_: PyRef<'_, Self>,
|
||||
#[pyo3(signature = (columns, auto_refresh_enabled=None, auto_refresh_interval_seconds=None, auto_refresh_resource_budget=None))]
|
||||
pub fn add_computed_columns<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
columns: Vec<(String, String)>,
|
||||
auto_refresh_enabled: Option<bool>,
|
||||
auto_refresh_interval_seconds: Option<u64>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
auto_refresh_resource_budget: Option<Bound<'py, PyDict>>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let inner = self_.inner_ref()?.clone();
|
||||
let options = ComputedColumnOptions {
|
||||
auto_refresh_enabled: auto_refresh_enabled.unwrap_or(true),
|
||||
auto_refresh_interval_seconds,
|
||||
auto_refresh_resource_budget: parse_resource_budget(
|
||||
auto_refresh_resource_budget.as_ref(),
|
||||
)?,
|
||||
};
|
||||
options.validate().infer_error()?;
|
||||
future_into_py(self_.py(), async move {
|
||||
@@ -2244,20 +2248,24 @@ impl Table {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (application_json, output_name, auto_refresh_enabled=None, auto_refresh_interval_seconds=None))]
|
||||
pub fn add_function_columns(
|
||||
self_: PyRef<'_, Self>,
|
||||
#[pyo3(signature = (application_json, output_name, auto_refresh_enabled=None, auto_refresh_interval_seconds=None, auto_refresh_resource_budget=None))]
|
||||
pub fn add_function_columns<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
application_json: String,
|
||||
output_name: Option<String>,
|
||||
auto_refresh_enabled: Option<bool>,
|
||||
auto_refresh_interval_seconds: Option<u64>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
auto_refresh_resource_budget: Option<Bound<'py, PyDict>>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let application =
|
||||
lancedb::function::FunctionApplication::from_json(&application_json).infer_error()?;
|
||||
let inner = self_.inner_ref()?.clone();
|
||||
let options = ComputedColumnOptions {
|
||||
auto_refresh_enabled: auto_refresh_enabled.unwrap_or(true),
|
||||
auto_refresh_interval_seconds,
|
||||
auto_refresh_resource_budget: parse_resource_budget(
|
||||
auto_refresh_resource_budget.as_ref(),
|
||||
)?,
|
||||
};
|
||||
options.validate().infer_error()?;
|
||||
future_into_py(self_.py(), async move {
|
||||
|
||||
Reference in New Issue
Block a user